feat: Add prayer chains, social sharing, and testimony wall

- Add PrayerChain component for visualizing linked prayers
- Add SharePrayer component with social media integration (Twitter, Facebook, WhatsApp, copy link)
- Create /testimonies page for public answered prayer stories
- Update Firestore rules for public testimony access
- Enhance Organization tab with prayer chains
- Add Testimonies navigation tab
- Fix Firebase Admin SDK integration for server-side operations
- Improve email system with proper server-side authentication
- Update prayer form to handle optional fields correctly
- Deploy updated Firestore security rules
This commit is contained in:
nmemmert committed 2025-12-17 13:50:21 -05:00
1 parent 7214fdb89a
commit d9784738e4
32 files changed
+3146 -3236

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
// Check if SMTP config exists in Firestore
// Run with: node check-smtp.js
import { initializeApp } from 'firebase/app';
import { getFirestore, doc, getDoc } from 'firebase/firestore';
const firebaseConfig = {
apiKey: "AIzaSyAHtvhdtBxNwf0UE3Fp5P0v1vITZKhLMJw",
authDomain: "prayer-app-6701f.firebaseapp.com",
projectId: "prayer-app-6701f",
storageBucket: "prayer-app-6701f.firebasestorage.app",
messagingSenderId: "555011794707",
appId: "1:555011794707:web:a3d60bdee742578cca1ce6"
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const checkSMTPConfig = async () => {
try {
console.log('🔍 Checking SMTP configuration...');
const configDoc = await getDoc(doc(db, 'config', 'smtp'));
if (configDoc.exists()) {
const config = configDoc.data();
console.log('✅ SMTP config found:');
console.log(' Host:', config.host);
console.log(' Port:', config.port);
console.log(' Secure:', config.secure);
console.log(' User:', config.user ? 'Set' : 'Not set');
console.log(' Pass:', config.pass ? 'Set' : 'Not set');
console.log(' From:', config.from);
} else {
console.log('❌ SMTP configuration not found in Firestore!');
console.log(' You need to add SMTP config to config/smtp document');
}
} catch (error) {
console.error('❌ Error checking SMTP config:', error);
}
};
checkSMTPConfig();
+61
View File
@@ -0,0 +1,61 @@
// Debug script to check SMTP configuration
// Run with: node debug-smtp.js
import { initializeApp } from 'firebase/app';
import { getFirestore, doc, getDoc } from 'firebase/firestore';
const firebaseConfig = {
apiKey: "AIzaSyAHtvhdtBxNwf0UE3Fp5P0v1vITZKhLMJw",
authDomain: "prayer-app-6701f.firebaseapp.com",
projectId: "prayer-app-6701f",
storageBucket: "prayer-app-6701f.firebasestorage.app",
messagingSenderId: "555011794707",
appId: "1:555011794707:web:a3d60bdee742578cca1ce6"
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
async function debugSMTP() {
try {
console.log('🔍 Checking SMTP configuration in Firestore...');
const configDoc = await getDoc(doc(db, 'config', 'smtp'));
if (configDoc.exists()) {
const config = configDoc.data();
console.log('✅ SMTP config found:');
console.log(` Host: ${config.host || 'MISSING'}`);
console.log(` Port: ${config.port || 'MISSING'}`);
console.log(` Secure: ${config.secure}`);
console.log(` User: ${config.user ? 'Set' : 'MISSING'}`);
console.log(` Pass: ${config.pass ? 'Set' : 'MISSING'}`);
console.log(` From: ${config.from || 'MISSING'}`);
// Check for common issues
if (!config.host) console.log('❌ ERROR: Host is missing');
if (!config.port) console.log('❌ ERROR: Port is missing');
if (!config.user) console.log('❌ ERROR: Username is missing');
if (!config.pass) console.log('❌ ERROR: Password is missing');
if (!config.from) console.log('❌ ERROR: From email is missing');
if (config.host && config.port && config.user && config.pass && config.from) {
console.log('✅ Configuration looks complete');
console.log('💡 If email still fails, check:');
console.log(' - SMTP server credentials are correct');
console.log(' - Firewall/antivirus not blocking connection');
console.log(' - Gmail: Using App Password (not regular password)');
}
} else {
console.log('❌ SMTP configuration not found in Firestore!');
console.log('💡 Set it up in Admin panel: http://localhost:3000/admin');
}
} catch (error) {
console.error('❌ Error checking SMTP config:', error.message);
if (error.code === 'permission-denied') {
console.log('💡 Permission denied - make sure Firestore rules allow config access');
}
}
}
debugSMTP();
+29 -3
View File
@@ -6,8 +6,33 @@ service cloud.firestore {
allow read, write: if request.auth != null && request.auth.uid == userId; allow read, write: if request.auth != null && request.auth.uid == userId;
} }
// Allow authenticated users to read/write their own prayers // Allow public reading of answered prayers for testimony wall
match /prayers/{prayerId} { match /prayers/{prayerId} {
allow read: if resource.data.answered == true;
allow read, write: if request.auth != null &&
resource.data.userId == request.auth.uid;
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid;
}
// Allow authenticated users to read/write their own prayer templates
match /prayerTemplates/{templateId} {
allow read, write: if request.auth != null &&
resource.data.userId == request.auth.uid;
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid;
}
// Allow authenticated users to read/write their own prayer goals
match /prayerGoals/{goalId} {
allow read, write: if request.auth != null &&
resource.data.userId == request.auth.uid;
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid;
}
// Allow authenticated users to read/write their own email templates
match /emailTemplates/{templateId} {
allow read, write: if request.auth != null && allow read, write: if request.auth != null &&
resource.data.userId == request.auth.uid; resource.data.userId == request.auth.uid;
allow create: if request.auth != null && allow create: if request.auth != null &&
@@ -24,9 +49,10 @@ service cloud.firestore {
allow read: if true; allow read: if true;
} }
// Allow authenticated users to read/write config (for admin settings) // Allow server-side API access to config (for SMTP settings)
match /config/{configId} { match /config/{configId} {
allow read, write: if request.auth != null; allow read: if true;
allow write: if request.auth != null;
} }
// Allow authenticated users to read/write shared prayers // Allow authenticated users to read/write shared prayers
+2 -2
View File
@@ -9,8 +9,8 @@ const nextConfig: NextConfig = {
/* config options here */ /* config options here */
}; };
const config = withPWA({ const config = process.env.NODE_ENV === 'production' ? withPWA({
dest: 'public', dest: 'public',
})(nextConfig); })(nextConfig) : nextConfig;
export default config; export default config;
+263 -51
View File
@@ -17,10 +17,12 @@
"next-i18next": "^15.4.2", "next-i18next": "^15.4.2",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-fetch": "^3.3.2",
"nodemailer": "^7.0.9", "nodemailer": "^7.0.9",
"react": "19.2.0", "react": "19.2.0",
"react-csv": "^2.2.2", "react-csv": "^2.2.2",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"react-hot-toast": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -30,9 +32,10 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^7.0.2", "@types/nodemailer": "^7.0.2",
"@types/react": "^19", "@types/react": "^19.2.7",
"@types/react-csv": "^1.1.10", "@types/react-csv": "^1.1.10",
"@types/react-dom": "^19", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.9",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.0", "eslint-config-next": "16.0.0",
"jest": "^30.2.0", "jest": "^30.2.0",
@@ -6724,12 +6727,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.2", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-csv": { "node_modules/@types/react-csv": {
@@ -6743,9 +6746,9 @@
} }
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "19.2.2", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@@ -8261,9 +8264,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.19", "version": "2.9.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
"integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==", "integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
@@ -8747,9 +8750,9 @@
} }
}, },
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
@@ -8759,6 +8762,15 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@@ -10080,6 +10092,29 @@
"bser": "2.1.1" "bser": "2.1.1"
} }
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fflate": { "node_modules/fflate": {
"version": "0.8.2", "version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -10330,6 +10365,18 @@
"node": ">= 0.12" "node": ">= 0.12"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -10426,6 +10473,32 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/gaxios/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/gaxios/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/gaxios/node_modules/uuid": { "node_modules/gaxios/node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -10439,6 +10512,22 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/gaxios/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/gaxios/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/gcp-metadata": { "node_modules/gcp-metadata": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
@@ -10666,6 +10755,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/google-auth-library": { "node_modules/google-auth-library": {
"version": "9.15.1", "version": "9.15.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
@@ -10740,6 +10838,34 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/google-gax/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/google-gax/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/google-gax/node_modules/uuid": { "node_modules/google-gax/node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -10754,6 +10880,24 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/google-gax/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/google-gax/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/google-logging-utils": { "node_modules/google-logging-utils": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
@@ -14196,46 +14340,42 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-fetch": { "node_modules/node-domexception": {
"version": "2.7.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"license": "MIT", "deprecated": "Use your platform's native DOMException instead",
"dependencies": { "funding": [
"whatwg-url": "^5.0.0" {
}, "type": "github",
"engines": { "url": "https://github.com/sponsors/jimmywarting"
"node": "4.x || >=6.0.0" },
}, {
"peerDependencies": { "type": "github",
"encoding": "^0.1.0" "url": "https://paypal.me/jimmywarting"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
} }
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
} }
}, },
"node_modules/node-fetch/node_modules/tr46": { "node_modules/node-fetch": {
"version": "0.0.3", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tr46": "~0.0.3", "data-uri-to-buffer": "^4.0.0",
"webidl-conversions": "^3.0.0" "fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
} }
}, },
"node_modules/node-forge": { "node_modules/node-forge": {
@@ -15037,6 +15177,23 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "16.1.5", "version": "16.1.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.1.5.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.1.5.tgz",
@@ -16324,6 +16481,34 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/teeny-request/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/teeny-request/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/teeny-request/node_modules/uuid": { "node_modules/teeny-request/node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -16338,6 +16523,24 @@
"uuid": "dist/bin/uuid" "uuid": "dist/bin/uuid"
} }
}, },
"node_modules/teeny-request/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/teeny-request/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -17057,6 +17260,15 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/web-vitals": { "node_modules/web-vitals": {
"version": "4.2.4", "version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
+7 -3
View File
@@ -2,6 +2,7 @@
"name": "prayer", "name": "prayer",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev --webpack",
"build": "next build --webpack", "build": "next build --webpack",
@@ -20,10 +21,12 @@
"next-i18next": "^15.4.2", "next-i18next": "^15.4.2",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-fetch": "^3.3.2",
"nodemailer": "^7.0.9", "nodemailer": "^7.0.9",
"react": "19.2.0", "react": "19.2.0",
"react-csv": "^2.2.2", "react-csv": "^2.2.2",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"react-hot-toast": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -33,9 +36,10 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^7.0.2", "@types/nodemailer": "^7.0.2",
"@types/react": "^19", "@types/react": "^19.2.7",
"@types/react-csv": "^1.1.10", "@types/react-csv": "^1.1.10",
"@types/react-dom": "^19", "@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.9",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.0", "eslint-config-next": "16.0.0",
"jest": "^30.2.0", "jest": "^30.2.0",
+1 -100
View File
@@ -1,100 +1 @@
/** if(!self.define){let e,s={};const c=(c,n)=>(c=new URL(c+".js",n).href,s[c]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=c,e.onload=s,document.head.appendChild(e)}else e=c,importScripts(c),s()}).then(()=>{let e=s[c];if(!e)throw new Error(`Module ${c} didn’t register its module`);return e}));self.define=(n,a)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let t={};const r=e=>c(e,i),o={module:{uri:i},exports:t,require:r};s[i]=Promise.all(n.map(e=>o[e]||r(e))).then(e=>(a(...e),t))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/cZNsOOz6AsqlSRDoNZrT0/_buildManifest.js",revision:"d2b421a3ddcac0c722ca24372451f8ce"},{url:"/_next/static/cZNsOOz6AsqlSRDoNZrT0/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/139.97da462391398dcd.js",revision:"97da462391398dcd"},{url:"/_next/static/chunks/148-615d1fc583e40846.js",revision:"615d1fc583e40846"},{url:"/_next/static/chunks/164f4fb6-17ad80440a3b3920.js",revision:"17ad80440a3b3920"},{url:"/_next/static/chunks/172-1a38936297cc38bc.js",revision:"1a38936297cc38bc"},{url:"/_next/static/chunks/199.cd10280de272c91c.js",revision:"cd10280de272c91c"},{url:"/_next/static/chunks/2f0b94e8-7527bef887b06150.js",revision:"7527bef887b06150"},{url:"/_next/static/chunks/462-8a0461a50c388d15.js",revision:"8a0461a50c388d15"},{url:"/_next/static/chunks/4bd1b696-628cfb3caad2bc1d.js",revision:"628cfb3caad2bc1d"},{url:"/_next/static/chunks/512-8f5e89a9857e3f80.js",revision:"8f5e89a9857e3f80"},{url:"/_next/static/chunks/646.363f2485dd3ba6c3.js",revision:"363f2485dd3ba6c3"},{url:"/_next/static/chunks/7508b87c-410b41f2acbcecc1.js",revision:"410b41f2acbcecc1"},{url:"/_next/static/chunks/931.18379530dad468f9.js",revision:"18379530dad468f9"},{url:"/_next/static/chunks/995.243eda67e2e24a2c.js",revision:"243eda67e2e24a2c"},{url:"/_next/static/chunks/ad2866b8.7839d30dea3f5edd.js",revision:"7839d30dea3f5edd"},{url:"/_next/static/chunks/app/_global-error/page-88bbc163b0c2e76d.js",revision:"88bbc163b0c2e76d"},{url:"/_next/static/chunks/app/_not-found/page-cb3917e7db174c1b.js",revision:"cb3917e7db174c1b"},{url:"/_next/static/chunks/app/admin/page-04bb41370f723a6a.js",revision:"04bb41370f723a6a"},{url:"/_next/static/chunks/app/api/send-email/route-88bbc163b0c2e76d.js",revision:"88bbc163b0c2e76d"},{url:"/_next/static/chunks/app/api/send-scheduled-emails/route-88bbc163b0c2e76d.js",revision:"88bbc163b0c2e76d"},{url:"/_next/static/chunks/app/layout-b2dce4554ba9ca0b.js",revision:"b2dce4554ba9ca0b"},{url:"/_next/static/chunks/app/page-dcf2deebe849ac91.js",revision:"dcf2deebe849ac91"},{url:"/_next/static/chunks/app/profile/page-7e7f10a8be5af96b.js",revision:"7e7f10a8be5af96b"},{url:"/_next/static/chunks/bc98253f.d6fc8a0138855acd.js",revision:"d6fc8a0138855acd"},{url:"/_next/static/chunks/dd8162e8-7de6382133d6a555.js",revision:"7de6382133d6a555"},{url:"/_next/static/chunks/framework-3311683cffde0ebf.js",revision:"3311683cffde0ebf"},{url:"/_next/static/chunks/main-47dd9496debeeb98.js",revision:"47dd9496debeeb98"},{url:"/_next/static/chunks/main-app-c78a9c42a9419335.js",revision:"c78a9c42a9419335"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-88bbc163b0c2e76d.js",revision:"88bbc163b0c2e76d"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-88bbc163b0c2e76d.js",revision:"88bbc163b0c2e76d"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-b63881b22d0c42fc.js",revision:"b63881b22d0c42fc"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-88bbc163b0c2e76d.js",revision:"88bbc163b0c2e76d"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-88bbc163b0c2e76d.js",revision:"88bbc163b0c2e76d"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-9ffa8922fe2c32be.js",revision:"9ffa8922fe2c32be"},{url:"/_next/static/css/6bef0c948e09345f.css",revision:"6bef0c948e09345f"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/file.svg",revision:"d09f95206c3fa0bb9bd9fefabfd0ea71"},{url:"/firebase-messaging-sw.js",revision:"04d66892653ef67e1151f1ec6c02420b"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icon-192x192.png",revision:"bbbd7efc0e31088ae0a3aeedLine truncated
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didn’t register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-e43f5367'], (function (workbox) { 'use strict';
importScripts();
self.skipWaiting();
workbox.clientsClaim();
workbox.registerRoute("/", new workbox.NetworkFirst({
"cacheName": "start-url",
plugins: [{
cacheWillUpdate: async ({
request,
response,
event,
state
}) => {
if (response && response.type === 'opaqueredirect') {
return new Response(response.body, {
status: 200,
statusText: 'OK',
headers: response.headers
});
}
return response;
}
}]
}), 'GET');
workbox.registerRoute(/.*/i, new workbox.NetworkOnly({
"cacheName": "dev",
plugins: []
}), 'GET');
}));
+1
View File
@@ -0,0 +1 @@
define(["exports"],function(t){"use strict";try{self["workbox:core:6.5.4"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:6.5.4"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class i extends r{constructor(t,e,s){super(({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)},e,s)}}class a{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)})}addCacheListener(){self.addEventListener("message",t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map(e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})}));t.waitUntil(s),t.ports&&t.ports[0]&&s.then(()=>t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:r,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let a=i&&i.handler;const o=t.method;if(!a&&this.i.has(o)&&(a=this.i.get(o)),!a)return;let c;try{c=a.handle({url:s,request:t,event:e,params:r})}catch(t){c=Promise.reject(t)}const h=i&&i.catchHandler;return c instanceof Promise&&(this.o||h)&&(c=c.catch(async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:r})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n})),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const r=this.t.get(s.method)||[];for(const i of r){let r;const a=i.match({url:t,sameOrigin:e,request:s,event:n});if(a)return r=a,(Array.isArray(r)&&0===r.length||a.constructor===Object&&0===Object.keys(a).length||"boolean"==typeof a)&&(r=void 0),{route:i,params:r}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let o;const c=()=>(o||(o=new a,o.addFetchListener(),o.addCacheListener()),o);function h(t,e,n){let a;if("string"==typeof t){const s=new URL(t,location.href);a=new r(({url:t})=>t.href===s.href,e,n)}else if(t instanceof RegExp)a=new i(t,e,n);else if("function"==typeof t)a=new r(t,e,n);else{if(!(t instanceof r))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});a=t}return c().registerRoute(a),a}try{self["workbox:strategies:6.5.4"]&&_()}catch(t){}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null},l={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},f=t=>[l.prefix,t,l.suffix].filter(t=>t&&t.length>0).join("-"),w=t=>t||f(l.precache),d=t=>t||f(l.runtime);function p(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class y{constructor(){this.promise=new Promise((t,e)=>{this.resolve=t,this.reject=e})}}const g=new Set;function m(t){return"string"==typeof t?new Request(t):t}class v{constructor(t,e){this.h={},Object.assign(this,e),this.event=e.event,this.u=t,this.l=new y,this.p=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.l.promise)}async fetch(t){const{event:e}=this;let n=m(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const r=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.u.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:i,response:t});return t}catch(t){throw r&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:r.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=m(t);let s;const{cacheName:n,matchOptions:r}=this.u,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},r),{cacheName:n});s=await caches.match(i,a);for(const t of this.iterateCLine truncated
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
// Setup SMTP configuration via browser Admin panel
// This is the easiest method!
console.log('🔧 SMTP Configuration Setup for Prayer App');
console.log('==========================================\n');
console.log('📋 The easiest way to configure SMTP is through the Admin panel:');
console.log('');
console.log('1. Open your browser and go to: http://localhost:3000/admin');
console.log('2. Log in with an admin account');
console.log('3. Look for the "SMTP Configuration" section');
console.log('4. Fill in your email provider details:');
console.log('');
console.log(' For Gmail:');
console.log(' - Host: smtp.gmail.com');
console.log(' - Port: 587');
console.log(' - Secure: unchecked');
console.log(' - Username: your-gmail@gmail.com');
console.log(' - Password: your Gmail App Password');
console.log(' - From Email: your-gmail@gmail.com');
console.log('');
console.log(' For Outlook:');
console.log(' - Host: smtp-mail.outlook.com');
console.log(' - Port: 587');
console.log(' - Secure: unchecked');
console.log(' - Username: your-email@outlook.com');
console.log(' - Password: your Outlook password');
console.log(' - From Email: your-email@outlook.com');
console.log('');
console.log('5. Click "Save SMTP Configuration"');
console.log('6. Test with: node test-email.js');
console.log('');
console.log('💡 To get Gmail App Password:');
console.log(' https://myaccount.google.com/apppasswords');
console.log(' (Enable 2FA first, then generate App Password for "Mail")');
+33
View File
@@ -0,0 +1,33 @@
'use client';
import { useAuth } from '@/context/AuthContext';
import Admin from '@/components/Admin';
import Navbar from '@/components/Navbar';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function AdminPage() {
const { user, isAdmin } = useAuth();
const router = useRouter();
useEffect(() => {
if (!user) {
router.push('/');
} else if (!isAdmin) {
router.push('/');
}
}, [user, isAdmin, router]);
if (!user || !isAdmin) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-black to-gray-900 text-white">
<Navbar />
<div className="max-w-6xl mx-auto p-6">
<Admin />
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
// Debug API endpoint to check SMTP config server-side
// This will work even without client authentication
import { db } from '@/lib/firebase';
import { doc, getDoc } from 'firebase/firestore';
export async function GET() {
try {
const configDoc = await getDoc(doc(db, 'config', 'smtp'));
if (configDoc.exists()) {
const config = configDoc.data();
return Response.json({
found: true,
host: config?.host,
port: config?.port,
secure: config?.secure,
user: config?.user ? 'SET' : 'MISSING',
pass: config?.pass ? 'SET' : 'MISSING',
from: config?.from,
hasAllFields: !!(config?.host && config?.port && config?.user && config?.pass && config?.from)
});
} else {
return Response.json({ found: false, error: 'SMTP config not found' });
}
} catch (error: any) {
return Response.json({ error: error.message });
}
}
+62 -7
View File
@@ -234,13 +234,38 @@ export async function POST(request: NextRequest) {
try { try {
const { email, subject, message } = await request.json(); const { email, subject, message } = await request.json();
console.log('📧 Attempting to send email to:', email);
// Get SMTP config from Firestore // Get SMTP config from Firestore
console.log('📧 Fetching SMTP config from Firestore...');
const configDoc = await getDoc(doc(db, 'config', 'smtp')); const configDoc = await getDoc(doc(db, 'config', 'smtp'));
if (!configDoc.exists()) { if (!configDoc.exists()) {
return NextResponse.json({ error: 'SMTP configuration not found' }, { status: 500 }); console.error('❌ SMTP configuration not found in Firestore');
return NextResponse.json({
error: 'SMTP configuration not found',
details: 'Please configure SMTP settings in the Admin panel at /admin'
}, { status: 500 });
} }
const smtpConfig = configDoc.data(); const smtpConfig = configDoc.data();
console.log('✅ SMTP config found, host:', smtpConfig?.host);
// Validate SMTP config
if (!smtpConfig?.host || !smtpConfig?.user || !smtpConfig?.pass || !smtpConfig?.from) {
console.error('❌ SMTP config incomplete:', {
hasHost: !!smtpConfig?.host,
hasUser: !!smtpConfig?.user,
hasPass: !!smtpConfig?.pass,
hasFrom: !!smtpConfig?.from
});
return NextResponse.json({
error: 'SMTP configuration incomplete',
details: 'Missing required SMTP fields. Please check Admin panel.'
}, { status: 500 });
}
console.log('📧 Creating email transporter...');
// Create transporter with dynamic config // Create transporter with dynamic config
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
@@ -253,8 +278,10 @@ export async function POST(request: NextRequest) {
}, },
}); });
console.log('📧 Sending email...');
// Send email // Send email
await transporter.sendMail({ const result = await transporter.sendMail({
from: smtpConfig.from, from: smtpConfig.from,
to: email, to: email,
subject, subject,
@@ -262,9 +289,37 @@ export async function POST(request: NextRequest) {
html: generatePrayerEmailHTML(message, subject), html: generatePrayerEmailHTML(message, subject),
}); });
return NextResponse.json({ success: true }); console.log('✅ Email sent successfully:', result.messageId);
} catch (error) { return NextResponse.json({ success: true, messageId: result.messageId });
console.error('Email send error:', error);
return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }); } catch (error: any) {
console.error('❌ Email send error:', error);
// Provide more specific error messages
if (error.code === 'ECONNREFUSED') {
return NextResponse.json({
error: 'SMTP connection failed',
details: 'Cannot connect to SMTP server. Check host and port.'
}, { status: 500 });
}
if (error.code === 'EAUTH') {
return NextResponse.json({
error: 'SMTP authentication failed',
details: 'Invalid username or password. For Gmail, use App Password.'
}, { status: 500 });
}
if (error.code === 'ESOCKET') {
return NextResponse.json({
error: 'SMTP socket error',
details: 'Network or firewall issue. Check internet connection.'
}, { status: 500 });
}
return NextResponse.json({
error: 'Failed to send email',
details: error.message || 'Unknown error occurred'
}, { status: 500 });
} }
} }
+203 -120
View File
@@ -1,43 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'; 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'; import { admin } from '@/lib/firebase-admin';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
function generatePrayerEmailHTML(message: string, subject: string) { function generatePrayerEmailHTML(message: string, subject: string) {
// Parse the message to extract prayer details // Parse the message to extract prayer details and stats
const lines = message.split('\n'); const lines = message.split('\n');
let currentPrayer = null; const stats = { total: 0, active: 0, answered: 0, archived: 0 };
const prayers = []; const activePrayers = [];
let summarySection = false; const answeredPrayers = [];
let inActiveSection = false;
let inAnsweredSection = false;
for (const line of lines) { for (const line of lines) {
if (line.includes('New prayer added') || line.includes('New praise added')) { if (line.includes('Total Prayers:')) {
currentPrayer = { type: '', date: '', text: '', journal: '', prayFor: '' }; stats.total = parseInt(line.split(':')[1].trim()) || 0;
} else if (line.startsWith('Date: ')) { } else if (line.includes('Active Prayers:')) {
if (currentPrayer) currentPrayer.date = line.replace('Date: ', ''); stats.active = parseInt(line.split(':')[1].trim()) || 0;
} else if (line.startsWith('Praying for: ')) { } else if (line.includes('Answered Prayers:')) {
if (currentPrayer) currentPrayer.prayFor = line.replace('Praying for: ', ''); stats.answered = parseInt(line.split(':')[1].trim()) || 0;
} else if (line.startsWith('Journal: ')) { } else if (line.includes('Archived Prayers:')) {
if (currentPrayer) currentPrayer.journal = line.replace('Journal: ', ''); stats.archived = parseInt(line.split(':')[1].trim()) || 0;
} else if (line.includes('--- Active Prayers Summary ---')) { } else if (line.includes('--- Your Active Prayers ---')) {
summarySection = true; inActiveSection = true;
prayers.push({ type: 'summary-header', text: 'Active Prayers Summary' }); inAnsweredSection = false;
} else if (summarySection && line.includes(' - ')) { } else if (line.includes('--- Recently Answered Prayers ---')) {
// Parse summary lines like "2025-01-15 - PRAYER: prayer text" inActiveSection = false;
inAnsweredSection = true;
} else if (inActiveSection && line.includes(' - ')) {
const match = line.match(/(\d{4}-\d{2}-\d{2}) - (PRAYER|PRAISE): (.+)/); const match = line.match(/(\d{4}-\d{2}-\d{2}) - (PRAYER|PRAISE): (.+)/);
if (match) { if (match) {
prayers.push({ activePrayers.push({
type: 'summary-item',
date: match[1], date: match[1],
prayerType: match[2].toLowerCase(), type: match[2].toLowerCase(),
text: match[3]
});
}
} else if (inAnsweredSection && line.includes(' - ')) {
const match = line.match(/(\d{4}-\d{2}-\d{2}) - (PRAYER|PRAISE): (.+)/);
if (match) {
answeredPrayers.push({
date: match[1],
type: match[2].toLowerCase(),
text: match[3] text: match[3]
}); });
} }
} else if (currentPrayer && !currentPrayer.text && line.trim()) {
currentPrayer.text = line;
prayers.push(currentPrayer);
currentPrayer = null;
} }
} }
@@ -171,10 +177,48 @@ function generatePrayerEmailHTML(message: string, subject: string) {
font-size: 16px; font-size: 16px;
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
.verse cite { .stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
margin: 25px 0;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.stat-number {
font-size: 28px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px; font-size: 14px;
opacity: 0.9; opacity: 0.9;
} }
.section {
margin: 30px 0;
}
.section h3 {
color: #333;
margin-bottom: 20px;
font-size: 20px;
}
.celebration {
text-align: center;
padding: 40px 20px;
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border-radius: 12px;
margin: 30px 0;
}
.celebration h3 {
color: #333;
margin-bottom: 10px;
}
</style> </style>
</head> </head>
<body> <body>
@@ -185,34 +229,57 @@ function generatePrayerEmailHTML(message: string, subject: string) {
</div> </div>
<div class="content"> <div class="content">
${prayers.map(prayer => { <!-- Prayer Statistics -->
if (prayer.type === 'summary-header') { <div class="stats-grid">
return ` <div class="stat-card">
<div class="summary-section"> <div class="stat-number">${stats.total}</div>
<h3>📚 ${prayer.text}</h3> <div class="stat-label">Total Prayers</div>
<p>Here are all your active prayers and praises:</p> </div>
</div> <div class="stat-card">
`; <div class="stat-number">${stats.active}</div>
} else if (prayer.type === 'summary-item') { <div class="stat-label">Active</div>
return ` </div>
<div class="summary-item"> <div class="stat-card">
<div class="date">${prayer.date ? new Date(prayer.date).toLocaleDateString() : 'Unknown Date'}</div> <div class="stat-number">${stats.answered}</div>
<div class="prayer-type">${prayer.prayerType}</div> <div class="stat-label">Answered</div>
<div class="prayer-text">${prayer.text}</div> </div>
</div> <div class="stat-card">
`; <div class="stat-number">${stats.archived}</div>
} else { <div class="stat-label">Archived</div>
return ` </div>
<div class="prayer-card"> </div>
<div class="prayer-type">${prayer.type}</div>
<div class="prayer-date">${prayer.date ? new Date(prayer.date).toLocaleDateString() : 'Unknown Date'}</div> ${activePrayers.length > 0 ? `
<div class="prayer-text">${prayer.text}</div> <div class="section">
${prayer.prayFor ? `<div class="pray-for">🙋 Praying for: ${prayer.prayFor}</div>` : ''} <h3>📝 Your Active Prayers</h3>
${prayer.journal && prayer.journal !== 'None' ? `<div class="prayer-journal">📝 Journal: ${prayer.journal}</div>` : ''} ${activePrayers.map(prayer => `
</div> <div class="prayer-card">
`; <div class="prayer-type">${prayer.type}</div>
} <div class="prayer-date">${new Date(prayer.date).toLocaleDateString()}</div>
}).join('')} <div class="prayer-text">${prayer.text}</div>
</div>
`).join('')}
</div>
` : `
<div class="celebration">
<h3>🎉 All Caught Up!</h3>
<p>You have no active prayers. Great job staying on top of your prayer life!</p>
</div>
`}
${answeredPrayers.length > 0 ? `
<div class="section">
<h3>🙏 Recently Answered Prayers</h3>
${answeredPrayers.map(prayer => `
<div class="prayer-card answered">
<div class="prayer-type">${prayer.type}</div>
<div class="prayer-date">${new Date(prayer.date).toLocaleDateString()}</div>
<div class="prayer-text">${prayer.text}</div>
</div>
`).join('')}
</div>
` : ''}
</div>
<div class="verse"> <div class="verse">
<blockquote>"Pray without ceasing"</blockquote> <blockquote>"Pray without ceasing"</blockquote>
@@ -234,9 +301,9 @@ export async function GET() {
try { try {
console.log('🔄 Checking for scheduled emails...'); console.log('🔄 Checking for scheduled emails...');
// Get SMTP config // Get SMTP config using Firebase Admin SDK
const configDoc = await getDoc(doc(db, 'config', 'smtp')); const configDoc = await admin.firestore().collection('config').doc('smtp').get();
if (!configDoc.exists()) { if (!configDoc.exists) {
console.log('❌ SMTP configuration not found'); console.log('❌ SMTP configuration not found');
return NextResponse.json({ message: 'SMTP configuration not found' }, { status: 500 }); return NextResponse.json({ message: 'SMTP configuration not found' }, { status: 500 });
} }
@@ -254,9 +321,8 @@ export async function GET() {
}, },
}); });
// Get all users who have reminder preferences // Get all users (send daily emails to everyone) using Firebase Admin SDK
const usersRef = collection(db, 'users'); const usersSnapshot = await admin.firestore().collection('users').get();
const usersSnapshot = await getDocs(usersRef);
let emailsSent = 0; let emailsSent = 0;
@@ -264,58 +330,78 @@ export async function GET() {
const userData = userDoc.data(); const userData = userDoc.data();
const userId = userDoc.id; const userId = userDoc.id;
// Check if user has reminder preferences // Skip users without email
if (!userData.reminderFrequency || userData.reminderFrequency === 'never') { if (!userData.email) {
continue; continue;
} }
// Check last email sent time // Check last email sent time - send daily
const lastEmailSent = userData.lastEmailSent ? new Date(userData.lastEmailSent.toDate()) : new Date(0); const lastEmailSent = userData.lastEmailSent ? new Date(userData.lastEmailSent.toDate()) : new Date(0);
const now = new Date(); const now = new Date();
let shouldSendEmail = false; // Send if more than 24 hours have passed
const frequency = userData.reminderFrequency; const shouldSendEmail = (now.getTime() - lastEmailSent.getTime()) > (24 * 60 * 60 * 1000);
if (frequency === 'daily') {
// Send if more than 24 hours have passed
shouldSendEmail = (now.getTime() - lastEmailSent.getTime()) > (24 * 60 * 60 * 1000);
} else if (frequency === 'weekly') {
// Send if more than 7 days have passed
shouldSendEmail = (now.getTime() - lastEmailSent.getTime()) > (7 * 24 * 60 * 60 * 1000);
} else if (frequency === 'monthly') {
// Send if more than 30 days have passed
shouldSendEmail = (now.getTime() - lastEmailSent.getTime()) > (30 * 24 * 60 * 60 * 1000);
}
if (!shouldSendEmail) { if (!shouldSendEmail) {
continue; continue;
} }
// Get user's active prayers // Get user's prayers for stats using Firebase Admin SDK
const prayersRef = collection(db, 'prayers'); const prayersSnapshot = await admin.firestore().collection('prayers').where('userId', '==', userId).get();
const prayersQuery = await getDocs(prayersRef); const userPrayers = prayersSnapshot.docs.map(doc => doc.data());
const userPrayers = prayersQuery.docs
.filter(doc => doc.data().userId === userId && !doc.data().archived)
.map(doc => doc.data());
if (userPrayers.length === 0) { // Calculate stats
continue; // No active prayers to send const activePrayers = userPrayers.filter(p => !p.archived);
const archivedPrayers = userPrayers.filter(p => p.archived);
const answeredPrayers = userPrayers.filter(p => p.answered);
const totalPrayers = userPrayers.length;
// Build email content with stats
let message = `--- Daily Prayer Summary ---\n\n`;
message += `📊 Your Prayer Statistics:\n`;
message += `• Total Prayers: ${totalPrayers}\n`;
message += `• Active Prayers: ${activePrayers.length}\n`;
message += `• Answered Prayers: ${answeredPrayers.length}\n`;
message += `• Archived Prayers: ${archivedPrayers.length}\n\n`;
if (activePrayers.length > 0) {
message += `--- Your Active Prayers ---\n`;
activePrayers.forEach(prayer => {
message += `\n${prayer.date} - ${prayer.type.toUpperCase()}: ${prayer.text}`;
if (prayer.prayFor) {
message += ` (Praying for: ${prayer.prayFor})`;
}
if (prayer.journal) {
message += `\n Journal: ${prayer.journal}`;
}
message += `\n`;
});
} else {
message += `🎉 Great job! You have no active prayers - all caught up!\n\n`;
} }
// Build email content if (answeredPrayers.length > 0) {
let message = `--- Active Prayers Summary ---\n`; message += `\n--- Recently Answered Prayers ---\n`;
userPrayers.forEach(prayer => { // Show answered prayers from the last 7 days
message += `\n${prayer.date} - ${prayer.type.toUpperCase()}: ${prayer.text}`; const recentAnswered = answeredPrayers.filter(p => {
if (prayer.prayFor) { const answeredDate = p.answeredDate ? new Date(p.answeredDate) : new Date(p.date);
message += ` (Praying for: ${prayer.prayFor})`; const weekAgo = new Date();
} weekAgo.setDate(weekAgo.getDate() - 7);
if (prayer.journal) { return answeredDate >= weekAgo;
message += `\n Journal: ${prayer.journal}`; });
}
message += `\n`;
});
const subject = `Your ${frequency} prayer reminder - ${userPrayers.length} active prayers`; if (recentAnswered.length > 0) {
recentAnswered.forEach(prayer => {
message += `\n${prayer.date} - ${prayer.type.toUpperCase()}: ${prayer.text}`;
if (prayer.answeredDate) {
message += ` (Answered: ${new Date(prayer.answeredDate).toLocaleDateString()})`;
}
message += `\n`;
});
}
}
const subject = `Daily Prayer Summary - ${activePrayers.length} active prayers`;
try { try {
// Send email // Send email
@@ -327,43 +413,40 @@ export async function GET() {
html: generatePrayerEmailHTML(message, subject), html: generatePrayerEmailHTML(message, subject),
}); });
// Send push notification if FCM token exists // Update last email sent time using Firebase Admin SDK
if (userData.fcmToken && admin.apps.length > 0) { await admin.firestore().collection('users').doc(userId).update({
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(), lastEmailSent: new Date(),
}); });
emailsSent++; emailsSent++;
console.log(`✅ Sent ${frequency} reminder email to ${userData.email}`); console.log(`✅ Sent daily summary email to ${userData.email}`);
} catch (emailError) { } catch (emailError) {
console.error(`❌ Failed to send email to ${userData.email}:`, emailError); console.error(`❌ Failed to send email to ${userData.email}:`, emailError);
} }
} }
console.log(`📧 Scheduled email check complete. Sent ${emailsSent} emails.`); console.log(`📧 Daily email summary complete. Sent ${emailsSent} emails.`);
return NextResponse.json({ return NextResponse.json({
message: `Scheduled email check complete. Sent ${emailsSent} emails.`, message: `Daily email summary complete. Sent ${emailsSent} emails.`,
emailsSent emailsSent
}); });
} catch (error) { } catch (error) {
console.error('❌ Scheduled email error:', error); console.error('❌ Scheduled email error:', error);
return NextResponse.json({ error: 'Failed to process scheduled emails' }, { status: 500 }); console.error('❌ Error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : 'Unknown'
});
return NextResponse.json({
error: 'Failed to process scheduled emails',
details: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
} }
}
export async function POST() {
// POST handler calls the same logic as GET for testing purposes
return GET();
} }
+15
View File
@@ -24,3 +24,18 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out forwards;
}
+5 -8
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { AuthProvider } from "@/context/AuthContext"; import ClientLayout from "@/components/ClientLayout";
import { ThemeProvider } from 'next-themes';
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -30,15 +29,13 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning={true}>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<ThemeProvider attribute="class" defaultTheme="dark"> <ClientLayout>
<AuthProvider> {children}
{children} </ClientLayout>
</AuthProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );
+679 -348
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useAuth } from '@/context/AuthContext';
import Profile from '@/components/Profile';
import Navbar from '@/components/Navbar';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function ProfilePage() {
const { user } = useAuth();
const router = useRouter();
useEffect(() => {
if (!user) {
router.push('/');
}
}, [user, router]);
if (!user) {
return null;
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-black to-gray-900 text-white">
<Navbar />
<div className="max-w-4xl mx-auto p-6">
<Profile />
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use client';
import { useState, useEffect } from 'react';
import { collection, query, where, orderBy, getDocs } from 'firebase/firestore';
import { db } from '@/lib/firebase';
import Navbar from '@/components/Navbar';
interface Testimony {
id: string;
text: string;
userId: string;
answeredDate: string;
tags?: string[];
type: 'prayer' | 'praise';
prayFor?: string;
}
export default function TestimoniesPage() {
const [testimonies, setTestimonies] = useState<Testimony[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'all' | 'prayer' | 'praise'>('all');
useEffect(() => {
const fetchTestimonies = async () => {
try {
const q = query(
collection(db, 'prayers'),
where('answered', '==', true),
orderBy('answeredDate', 'desc')
);
const querySnapshot = await getDocs(q);
const testimoniesData = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})) as Testimony[];
setTestimonies(testimoniesData);
} catch (error) {
console.error('Error fetching testimonies:', error);
} finally {
setLoading(false);
}
};
fetchTestimonies();
}, []);
const filteredTestimonies = testimonies.filter(testimony =>
filter === 'all' || testimony.type === filter
);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-black to-gray-900 text-white">
<Navbar />
<div className="max-w-4xl mx-auto p-6">
<div className="animate-pulse space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-gray-800/50 rounded-lg p-6">
<div className="h-4 bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-3 bg-gray-700 rounded w-1/2"></div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-black to-gray-900 text-white">
<Navbar />
<div className="max-w-4xl mx-auto p-6">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold mb-4">🙏 Testimony Wall</h1>
<p className="text-gray-300">Celebrating answered prayers and praises</p>
</div>
{/* Filter Buttons */}
<div className="flex justify-center space-x-4 mb-8">
<button
onClick={() => setFilter('all')}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'all'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
All Testimonies
</button>
<button
onClick={() => setFilter('prayer')}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'prayer'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
🙏 Prayers
</button>
<button
onClick={() => setFilter('praise')}
className={`px-4 py-2 rounded-lg transition-colors ${
filter === 'praise'
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
🙌 Praises
</button>
</div>
<div className="space-y-6">
{filteredTestimonies.map((testimony) => (
<div key={testimony.id} className="bg-gray-800/50 backdrop-blur-sm rounded-2xl p-6 shadow-2xl border border-gray-700/50">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-2">
<span className="text-2xl">{testimony.type === 'prayer' ? '🙏' : '🙌'}</span>
<span className="text-green-400 font-semibold">
Answered {testimony.type === 'prayer' ? 'Prayer' : 'Praise'}
</span>
{testimony.prayFor && (
<span className="text-sm text-gray-400">for {testimony.prayFor}</span>
)}
</div>
<span className="text-sm text-gray-400">
{new Date(testimony.answeredDate).toLocaleDateString()}
</span>
</div>
<p className="text-gray-200 mb-4 leading-relaxed">{testimony.text}</p>
{testimony.tags && testimony.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{testimony.tags.map((tag, index) => (
<span key={index} className="px-2 py-1 bg-blue-600/20 text-blue-300 rounded-full text-xs">
#{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
{filteredTestimonies.length === 0 && (
<div className="text-center py-12">
<div className="text-6xl mb-4">🙏</div>
<h2 className="text-2xl font-semibold mb-2">No Testimonies Yet</h2>
<p className="text-gray-400">
{filter === 'all'
? 'Answered prayers will appear here to inspire others.'
: `No answered ${filter === 'prayer' ? 'prayers' : 'praises'} yet.`
}
</p>
</div>
)}
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { ThemeProvider } from 'next-themes';
import { AuthProvider } from '@/context/AuthContext';
import { Toaster } from 'react-hot-toast';
export default function ClientLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ThemeProvider attribute="class" defaultTheme="dark">
<AuthProvider>
{children}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#1f2937',
color: '#fff',
border: '1px solid #374151',
},
}}
/>
</AuthProvider>
</ThemeProvider>
);
}
+288
View File
@@ -0,0 +1,288 @@
import React, { useState, useEffect } from 'react';
import { db } from '../lib/firebase';
import { collection, addDoc, updateDoc, deleteDoc, doc, getDocs, query, where } from 'firebase/firestore';
import { useAuth } from '../context/AuthContext';
interface EmailTemplate {
id: string;
name: string;
subject: string;
body: string;
type: 'prayer_update' | 'reminder' | 'summary';
createdAt: string;
updatedAt: string;
}
interface EmailTemplatesManagerProps {
onTemplateSelect?: (templateId: string) => void;
selectedTemplateId?: string;
}
export default function EmailTemplatesManager({ onTemplateSelect, selectedTemplateId }: EmailTemplatesManagerProps) {
const { user } = useAuth();
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [form, setForm] = useState({
name: '',
subject: '',
body: '',
type: 'prayer_update' as EmailTemplate['type']
});
useEffect(() => {
if (user) {
loadTemplates();
}
}, [user]);
const loadTemplates = async () => {
if (!user) return;
try {
const q = query(collection(db, 'emailTemplates'), where('userId', '==', user.uid));
const querySnapshot = await getDocs(q);
const templatesData = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})) as EmailTemplate[];
setTemplates(templatesData);
} catch (error) {
console.error('Error loading email templates:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
try {
const templateData = {
...form,
userId: user.uid,
updatedAt: new Date().toISOString()
};
if (editingTemplate) {
await updateDoc(doc(db, 'emailTemplates', editingTemplate.id), templateData);
} else {
await addDoc(collection(db, 'emailTemplates'), {
...templateData,
createdAt: new Date().toISOString()
});
}
setForm({ name: '', subject: '', body: '', type: 'prayer_update' });
setShowForm(false);
setEditingTemplate(null);
loadTemplates();
} catch (error) {
console.error('Error saving email template:', error);
}
};
const handleEdit = (template: EmailTemplate) => {
setEditingTemplate(template);
setForm({
name: template.name,
subject: template.subject,
body: template.body,
type: template.type
});
setShowForm(true);
};
const handleDelete = async (templateId: string) => {
if (!confirm('Are you sure you want to delete this email template?')) return;
try {
await deleteDoc(doc(db, 'emailTemplates', templateId));
loadTemplates();
} catch (error) {
console.error('Error deleting email template:', error);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingTemplate(null);
setForm({ name: '', subject: '', body: '', type: 'prayer_update' });
};
const getTypeLabel = (type: EmailTemplate['type']) => {
switch (type) {
case 'prayer_update': return 'Prayer Update';
case 'reminder': return 'Reminder';
case 'summary': return 'Summary';
default: return type;
}
};
const getTypeColor = (type: EmailTemplate['type']) => {
switch (type) {
case 'prayer_update': return 'bg-blue-600';
case 'reminder': return 'bg-orange-600';
case 'summary': return 'bg-green-600';
default: return 'bg-gray-600';
}
};
if (loading) {
return <div className="text-white">Loading email templates...</div>;
}
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">📧 Email Templates</h2>
<button
onClick={() => setShowForm(true)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
+ New Template
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="mb-6 p-4 bg-gray-700 rounded-lg">
<h3 className="text-lg font-semibold text-white mb-4">
{editingTemplate ? 'Edit Email Template' : 'Create New Email Template'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Template Name</label>
<input
type="text"
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Weekly Prayer Update"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Template Type</label>
<select
value={form.type}
onChange={e => setForm(prev => ({ ...prev, type: e.target.value as EmailTemplate['type'] }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="prayer_update">Prayer Update</option>
<option value="reminder">Reminder</option>
<option value="summary">Summary</option>
</select>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Email Subject</label>
<input
type="text"
value={form.subject}
onChange={e => setForm(prev => ({ ...prev, subject: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Your Weekly Prayer Update"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Email Body</label>
<textarea
value={form.body}
onChange={e => setForm(prev => ({ ...prev, body: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[150px] resize-none"
placeholder={`Write your email template here. Available placeholders:
{userName} - User's name
{prayerText} - The prayer text
{prayFor} - Who they're praying for
{date} - Date of prayer
{type} - Prayer or praise`}
required
/>
</div>
<div className="text-sm text-gray-400 mb-4">
<strong>Available placeholders:</strong> {'{userName}'}, {'{prayerText}'}, {'{prayFor}'}, {'{date}'}, {'{type}'}
</div>
<div className="flex space-x-3">
<button
type="submit"
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
{editingTemplate ? 'Update' : 'Create'} Template
</button>
<button
type="button"
onClick={handleCancel}
className="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
)}
<div className="space-y-4">
{templates.length === 0 ? (
<div className="text-center py-8 text-gray-400">
No email templates created yet. Create your first template to get started!
</div>
) : (
templates.map(template => (
<div key={template.id} className="p-4 bg-gray-700 rounded-lg">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-lg font-semibold text-white">{template.name}</h3>
<span className={`px-2 py-1 text-white text-xs rounded-full ${getTypeColor(template.type)}`}>
{getTypeLabel(template.type)}
</span>
</div>
<p className="text-gray-300 text-sm mb-2">
<strong>Subject:</strong> {template.subject}
</p>
</div>
<div className="flex space-x-2">
{onTemplateSelect && (
<button
onClick={() => onTemplateSelect(template.id)}
className={`px-3 py-1 rounded text-xs transition-colors ${
selectedTemplateId === template.id
? 'bg-green-600 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{selectedTemplateId === template.id ? 'Selected' : 'Select'}
</button>
)}
<button
onClick={() => handleEdit(template)}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(template.id)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs transition-colors"
>
Delete
</button>
</div>
</div>
<div className="bg-gray-600 p-3 rounded text-gray-200 text-sm whitespace-pre-wrap max-h-32 overflow-y-auto">
{template.body}
</div>
</div>
))
)}
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
export default function LoadingSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-700 rounded w-3/4"></div>
<div className="h-4 bg-gray-700 rounded w-1/2"></div>
<div className="h-4 bg-gray-700 rounded w-5/6"></div>
</div>
);
}
+82 -48
View File
@@ -9,15 +9,18 @@ export default function Login() {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [isSignup, setIsSignup] = useState(false); const [isSignup, setIsSignup] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login, signup } = useAuth(); const { login, signup } = useAuth();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setLoading(true);
if (isSignup) { if (isSignup) {
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Passwords do not match'); setError('Passwords do not match');
setLoading(false);
return; return;
} }
try { try {
@@ -32,62 +35,93 @@ export default function Login() {
setError(err instanceof Error ? err.message : 'An error occurred'); setError(err instanceof Error ? err.message : 'An error occurred');
} }
} }
setLoading(false);
}; };
return ( return (
<div className="min-h-screen bg-black text-white flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-br from-gray-900 to-black text-white flex items-center justify-center p-4">
<form onSubmit={handleSubmit} className="bg-gray-800 p-8 rounded-lg w-full max-w-md"> <div className="w-full max-w-md">
<h1 className="text-2xl font-bold mb-6 text-center"> <div className="text-center mb-8">
{isSignup ? 'Sign Up for Prayer App' : 'Login to Prayer App'} <h1 className="text-4xl font-bold mb-2">Prayer App</h1>
</h1> <p className="text-gray-400">Manage your spiritual journey</p>
{error && <p className="text-red-500 mb-4">{error}</p>}
<div className="mb-4">
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded"
required
/>
</div> </div>
<div className="mb-4"> <form onSubmit={handleSubmit} className="bg-gray-800 p-8 rounded-xl shadow-2xl space-y-6">
<label className="block mb-2">Password</label> <h2 className="text-2xl font-semibold text-center">
<input {isSignup ? 'Create Account' : 'Welcome Back'}
type="password" </h2>
value={password} {error && (
onChange={(e) => setPassword(e.target.value)} <div className="bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded-lg">
className="w-full p-2 bg-gray-700 border border-gray-600 rounded" {error}
required </div>
/> )}
</div> <div>
{isSignup && ( <label className="block text-sm font-medium text-gray-300 mb-2">Email</label>
<div className="mb-6">
<label className="block mb-2">Confirm Password</label>
<input <input
type="password" type="email"
value={confirmPassword} value={email}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setEmail(e.target.value)}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded" autoFocus
className="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Enter your email"
required required
/> />
</div> </div>
)} <div>
<button type="submit" className="w-full p-2 bg-blue-600 hover:bg-blue-700 rounded mb-4"> <label className="block text-sm font-medium text-gray-300 mb-2">Password</label>
{isSignup ? 'Sign Up' : 'Login'} <input
</button> type="password"
<button value={password}
type="button" onChange={(e) => setPassword(e.target.value)}
onClick={() => { className="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
setIsSignup(!isSignup); placeholder="Enter your password"
setError(''); required
setConfirmPassword(''); />
}} </div>
className="w-full p-2 bg-gray-600 hover:bg-gray-700 rounded" {isSignup && (
> <div>
{isSignup ? 'Already have an account? Login' : 'Need an account? Sign Up'} <label className="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
</button> <input
</form> type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Confirm your password"
required
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full p-3 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed rounded-lg font-medium transition-colors flex items-center justify-center"
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</>
) : (
isSignup ? 'Create Account' : 'Sign In'
)}
</button>
<div className="text-center">
<button
type="button"
onClick={() => {
setIsSignup(!isSignup);
setError('');
setConfirmPassword('');
}}
className="text-blue-400 hover:text-blue-300 transition-colors"
>
{isSignup ? 'Already have an account? Sign In' : 'Need an account? Sign Up'}
</button>
</div>
</form>
</div>
</div> </div>
); );
} }
+207
View File
@@ -0,0 +1,207 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useRouter } from 'next/navigation';
interface NavbarProps {
activeTab?: string;
onTabChange?: (tab: string) => void;
onSearchClear?: () => void;
onPageReset?: () => void;
}
export default function Navbar({ activeTab, onTabChange, onSearchClear, onPageReset }: NavbarProps) {
const { user, logout, isAdmin } = useAuth();
const router = useRouter();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
if (!user) return null;
const navigationTabs = [
{ key: 'dashboard', label: 'Dashboard', icon: '📊' },
{ key: 'add', label: 'Add Prayer', icon: '➕' },
{ key: 'active', label: 'Active', icon: '📖' },
{ key: 'archived', label: 'Archived', icon: '📂' },
{ key: 'search', label: 'Search', icon: '🔍' },
{ key: 'community', label: 'Community', icon: '🌍' },
{ key: 'organization', label: 'Organization', icon: '🗂️' },
{ key: 'testimonies', label: 'Testimonies', icon: '🙌' },
];
const handleTabChange = (tabKey: string) => {
if (tabKey === 'testimonies') {
router.push('/testimonies');
setIsMobileMenuOpen(false);
return;
}
if (onTabChange) {
onTabChange(tabKey);
}
if (tabKey !== 'search' && onSearchClear) {
onSearchClear();
}
if (onPageReset) {
onPageReset();
}
setIsMobileMenuOpen(false);
};
return (
<nav className="bg-gray-900/95 backdrop-blur-sm border-b border-gray-800/50 px-4 py-3 shadow-lg">
<div className="max-w-6xl mx-auto flex items-center justify-between">
{/* Logo/Brand */}
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">🙏</span>
</div>
<button
onClick={() => {
handleTabChange('dashboard');
router.push('/');
}}
className="text-lg md:text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent hover:from-blue-300 hover:to-purple-400 transition-colors"
>
Prayer App
</button>
</div>
{/* Desktop Navigation - Hidden on mobile */}
<div className="hidden lg:flex items-center space-x-2">
{navigationTabs.map(({ key, label, icon }) => (
<button
key={key}
onClick={() => handleTabChange(key)}
className={`px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center space-x-2 text-sm ${
activeTab === key
? 'bg-blue-600 text-white shadow-lg'
: 'text-gray-300 hover:text-white hover:bg-gray-800/50'
}`}
>
<span>{icon}</span>
<span>{label}</span>
</button>
))}
{isAdmin && (
<button
onClick={() => router.push('/admin')}
className="text-gray-300 hover:text-white transition-colors px-3 py-2 rounded-lg hover:bg-gray-800/50 text-sm"
>
Admin
</button>
)}
<button
onClick={() => router.push('/profile')}
className="text-gray-300 hover:text-white transition-colors px-3 py-2 rounded-lg hover:bg-gray-800/50 text-sm"
>
Profile
</button>
<button
onClick={logout}
className="bg-red-600/20 hover:bg-red-600/30 text-red-400 hover:text-red-300 px-4 py-2 rounded-lg transition-all duration-200 border border-red-500/30 text-sm"
>
Logout
</button>
</div>
{/* Mobile Menu Button */}
<div className="lg:hidden">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="text-gray-300 hover:text-white p-2 rounded-lg hover:bg-gray-800/50 transition-colors"
aria-label="Toggle mobile menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isMobileMenuOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
</div>
</div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="lg:hidden border-t border-gray-800/50 mt-3 pt-3">
<div className="flex flex-col space-y-1">
{/* Main Navigation Tabs */}
{navigationTabs.map(({ key, label, icon }) => (
<button
key={key}
onClick={() => handleTabChange(key)}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center space-x-3 ${
activeTab === key
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:text-white hover:bg-gray-800/50'
}`}
>
<span className="text-lg">{icon}</span>
<span className="font-medium">{label}</span>
{activeTab === key && (
<span className="ml-auto text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded-full">
Active
</span>
)}
</button>
))}
{/* Separator */}
<div className="border-t border-gray-700 my-2"></div>
{/* Additional Options */}
{isAdmin && (
<button
onClick={() => {
router.push('/admin');
setIsMobileMenuOpen(false);
}}
className="w-full text-left text-gray-300 hover:text-white transition-colors px-4 py-3 rounded-lg hover:bg-gray-800/50 flex items-center space-x-3"
>
<span className="text-lg">⚙️</span>
<span>Admin Panel</span>
</button>
)}
<button
onClick={() => {
router.push('/profile');
setIsMobileMenuOpen(false);
}}
className="w-full text-left text-gray-300 hover:text-white transition-colors px-4 py-3 rounded-lg hover:bg-gray-800/50 flex items-center space-x-3"
>
<span className="text-lg">👤</span>
<span>Profile</span>
</button>
<button
onClick={() => {
logout();
setIsMobileMenuOpen(false);
}}
className="w-full text-left bg-red-600/20 hover:bg-red-600/30 text-red-400 hover:text-red-300 px-4 py-3 rounded-lg transition-all duration-200 border border-red-500/30 flex items-center space-x-3"
>
<span className="text-lg">🚪</span>
<span>Logout</span>
</button>
</div>
</div>
)}
</nav>
);
}
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
interface Prayer {
id: string;
text: string;
type: 'prayer' | 'praise';
answered?: boolean;
chainId?: string;
chainOrder?: number;
}
interface PrayerChainProps {
prayers: Prayer[];
onChainClick: (chainId: string) => void;
}
export default function PrayerChain({ prayers, onChainClick }: PrayerChainProps) {
const chains = prayers.reduce((acc, prayer) => {
if (prayer.chainId) {
if (!acc[prayer.chainId]) acc[prayer.chainId] = [];
acc[prayer.chainId].push(prayer);
}
return acc;
}, {} as Record<string, Prayer[]>);
return (
<div className="space-y-4">
{Object.entries(chains).map(([chainId, chainPrayers]) => (
<div key={chainId} className="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
<h3 className="text-lg font-semibold mb-2 text-white">Prayer Chain</h3>
<div className="flex flex-col space-y-2">
{chainPrayers
.sort((a, b) => (a.chainOrder || 0) - (b.chainOrder || 0))
.map((prayer, index) => (
<div key={prayer.id} className="flex items-center space-x-2">
<div className="w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center text-xs text-white font-bold">
{index + 1}
</div>
<div className="flex-1 text-sm text-gray-200 truncate">{prayer.text}</div>
{prayer.answered && <span className="text-green-400">✓</span>}
</div>
))}
</div>
<button
onClick={() => onChainClick(chainId)}
className="mt-2 text-blue-400 hover:text-blue-300 text-sm underline"
>
View Full Chain
</button>
</div>
))}
</div>
);
}
+316
View File
@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
import { db } from '../lib/firebase';
import { collection, addDoc, updateDoc, deleteDoc, doc, getDocs, query, where } from 'firebase/firestore';
import { useAuth } from '../context/AuthContext';
interface PrayerGoal {
id: string;
title: string;
description: string;
targetCount: number;
currentCount: number;
category: string;
startDate: string;
endDate: string;
isCompleted: boolean;
createdAt: string;
updatedAt: string;
}
interface PrayerGoalsManagerProps {
onGoalSelect?: (goalId: string) => void;
selectedGoalId?: string;
}
export default function PrayerGoalsManager({ onGoalSelect, selectedGoalId }: PrayerGoalsManagerProps) {
const { user } = useAuth();
const [goals, setGoals] = useState<PrayerGoal[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingGoal, setEditingGoal] = useState<PrayerGoal | null>(null);
const [form, setForm] = useState({
title: '',
description: '',
targetCount: 1,
category: '',
startDate: '',
endDate: ''
});
useEffect(() => {
if (user) {
loadGoals();
}
}, [user]);
const loadGoals = async () => {
if (!user) return;
try {
const q = query(collection(db, 'prayerGoals'), where('userId', '==', user.uid));
const querySnapshot = await getDocs(q);
const goalsData = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})) as PrayerGoal[];
setGoals(goalsData);
} catch (error) {
console.error('Error loading goals:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
try {
const goalData = {
...form,
currentCount: editingGoal ? editingGoal.currentCount : 0,
isCompleted: false,
userId: user.uid,
updatedAt: new Date().toISOString()
};
if (editingGoal) {
await updateDoc(doc(db, 'prayerGoals', editingGoal.id), goalData);
} else {
await addDoc(collection(db, 'prayerGoals'), {
...goalData,
createdAt: new Date().toISOString()
});
}
setForm({ title: '', description: '', targetCount: 1, category: '', startDate: '', endDate: '' });
setShowForm(false);
setEditingGoal(null);
loadGoals();
} catch (error) {
console.error('Error saving goal:', error);
}
};
const handleEdit = (goal: PrayerGoal) => {
setEditingGoal(goal);
setForm({
title: goal.title,
description: goal.description,
targetCount: goal.targetCount,
category: goal.category,
startDate: goal.startDate,
endDate: goal.endDate
});
setShowForm(true);
};
const handleDelete = async (goalId: string) => {
if (!confirm('Are you sure you want to delete this goal?')) return;
try {
await deleteDoc(doc(db, 'prayerGoals', goalId));
loadGoals();
} catch (error) {
console.error('Error deleting goal:', error);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingGoal(null);
setForm({ title: '', description: '', targetCount: 1, category: '', startDate: '', endDate: '' });
};
const getProgressPercentage = (current: number, target: number) => {
return Math.min((current / target) * 100, 100);
};
if (loading) {
return <div className="text-white">Loading goals...</div>;
}
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">🎯 Prayer Goals</h2>
<button
onClick={() => setShowForm(true)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
+ New Goal
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="mb-6 p-4 bg-gray-700 rounded-lg">
<h3 className="text-lg font-semibold text-white mb-4">
{editingGoal ? 'Edit Goal' : 'Create New Goal'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Goal Title</label>
<input
type="text"
value={form.title}
onChange={e => setForm(prev => ({ ...prev, title: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Pray for 30 days"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Target Count</label>
<input
type="number"
min="1"
value={form.targetCount}
onChange={e => setForm(prev => ({ ...prev, targetCount: parseInt(e.target.value) || 1 }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., 30"
required
/>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Category</label>
<input
type="text"
value={form.category}
onChange={e => setForm(prev => ({ ...prev, category: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Personal, Family, Ministry"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Description</label>
<textarea
value={form.description}
onChange={e => setForm(prev => ({ ...prev, description: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] resize-none"
placeholder="Describe your prayer goal..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Start Date</label>
<input
type="date"
value={form.startDate}
onChange={e => setForm(prev => ({ ...prev, startDate: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">End Date (Optional)</label>
<input
type="date"
value={form.endDate}
onChange={e => setForm(prev => ({ ...prev, endDate: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex space-x-3">
<button
type="submit"
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
{editingGoal ? 'Update' : 'Create'} Goal
</button>
<button
type="button"
onClick={handleCancel}
className="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
)}
<div className="space-y-4">
{goals.length === 0 ? (
<div className="text-center py-8 text-gray-400">
No goals created yet. Create your first prayer goal to get started!
</div>
) : (
goals.map(goal => {
const progressPercent = getProgressPercentage(goal.currentCount, goal.targetCount);
return (
<div key={goal.id} className="p-4 bg-gray-700 rounded-lg">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-lg font-semibold text-white">{goal.title}</h3>
{goal.isCompleted && (
<span className="px-2 py-1 bg-green-600 text-white text-xs rounded-full">
Completed
</span>
)}
{goal.category && (
<span className="px-2 py-1 bg-blue-600 text-white text-xs rounded-full">
{goal.category}
</span>
)}
</div>
{goal.description && (
<p className="text-gray-300 text-sm mb-3">{goal.description}</p>
)}
<div className="flex items-center space-x-4 text-sm text-gray-400">
<span>Progress: {goal.currentCount} / {goal.targetCount}</span>
{goal.startDate && <span>Started: {new Date(goal.startDate).toLocaleDateString()}</span>}
{goal.endDate && <span>Ends: {new Date(goal.endDate).toLocaleDateString()}</span>}
</div>
</div>
<div className="flex space-x-2">
{onGoalSelect && (
<button
onClick={() => onGoalSelect(goal.id)}
className={`px-3 py-1 rounded text-xs transition-colors ${
selectedGoalId === goal.id
? 'bg-green-600 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{selectedGoalId === goal.id ? 'Selected' : 'Select'}
</button>
)}
<button
onClick={() => handleEdit(goal)}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(goal.id)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs transition-colors"
>
Delete
</button>
</div>
</div>
<div className="w-full bg-gray-600 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
></div>
</div>
<div className="text-right text-xs text-gray-400 mt-1">
{Math.round(progressPercent)}% Complete
</div>
</div>
);
})
)}
</div>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
import { db } from '../lib/firebase';
import { collection, addDoc, updateDoc, deleteDoc, doc, getDocs, query, where } from 'firebase/firestore';
import { useAuth } from '../context/AuthContext';
interface PrayerTemplate {
id: string;
name: string;
description: string;
templateText: string;
category: string;
createdAt: string;
updatedAt: string;
}
interface PrayerTemplatesManagerProps {
onTemplateSelect?: (templateId: string) => void;
selectedTemplateId?: string;
}
export default function PrayerTemplatesManager({ onTemplateSelect, selectedTemplateId }: PrayerTemplatesManagerProps) {
const { user } = useAuth();
const [templates, setTemplates] = useState<PrayerTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<PrayerTemplate | null>(null);
const [form, setForm] = useState({
name: '',
description: '',
templateText: '',
category: ''
});
useEffect(() => {
if (user) {
loadTemplates();
}
}, [user]);
const loadTemplates = async () => {
if (!user) return;
try {
const q = query(collection(db, 'prayerTemplates'), where('userId', '==', user.uid));
const querySnapshot = await getDocs(q);
const templatesData = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
})) as PrayerTemplate[];
setTemplates(templatesData);
} catch (error) {
console.error('Error loading templates:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
try {
const templateData = {
...form,
userId: user.uid,
updatedAt: new Date().toISOString()
};
if (editingTemplate) {
await updateDoc(doc(db, 'prayerTemplates', editingTemplate.id), templateData);
} else {
await addDoc(collection(db, 'prayerTemplates'), {
...templateData,
createdAt: new Date().toISOString()
});
}
setForm({ name: '', description: '', templateText: '', category: '' });
setShowForm(false);
setEditingTemplate(null);
loadTemplates();
} catch (error) {
console.error('Error saving template:', error);
}
};
const handleEdit = (template: PrayerTemplate) => {
setEditingTemplate(template);
setForm({
name: template.name,
description: template.description,
templateText: template.templateText,
category: template.category
});
setShowForm(true);
};
const handleDelete = async (templateId: string) => {
if (!confirm('Are you sure you want to delete this template?')) return;
try {
await deleteDoc(doc(db, 'prayerTemplates', templateId));
loadTemplates();
} catch (error) {
console.error('Error deleting template:', error);
}
};
const handleCancel = () => {
setShowForm(false);
setEditingTemplate(null);
setForm({ name: '', description: '', templateText: '', category: '' });
};
if (loading) {
return <div className="text-white">Loading templates...</div>;
}
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">📋 Prayer Templates</h2>
<button
onClick={() => setShowForm(true)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
+ New Template
</button>
</div>
{showForm && (
<form onSubmit={handleSubmit} className="mb-6 p-4 bg-gray-700 rounded-lg">
<h3 className="text-lg font-semibold text-white mb-4">
{editingTemplate ? 'Edit Template' : 'Create New Template'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Template Name</label>
<input
type="text"
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Healing Prayer"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Category</label>
<input
type="text"
value={form.category}
onChange={e => setForm(prev => ({ ...prev, category: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="e.g., Health, Family, Work"
/>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Description</label>
<input
type="text"
value={form.description}
onChange={e => setForm(prev => ({ ...prev, description: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Brief description of this template"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">Template Text</label>
<textarea
value={form.templateText}
onChange={e => setForm(prev => ({ ...prev, templateText: e.target.value }))}
className="w-full p-3 bg-gray-600 border border-gray-500 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[120px] resize-none"
placeholder="Write your prayer template here. Use {name} for placeholders..."
required
/>
</div>
<div className="flex space-x-3">
<button
type="submit"
className="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
{editingTemplate ? 'Update' : 'Create'} Template
</button>
<button
type="button"
onClick={handleCancel}
className="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
)}
<div className="space-y-4">
{templates.length === 0 ? (
<div className="text-center py-8 text-gray-400">
No templates created yet. Create your first template to get started!
</div>
) : (
templates.map(template => (
<div key={template.id} className="p-4 bg-gray-700 rounded-lg">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="text-lg font-semibold text-white">{template.name}</h3>
{template.category && (
<span className="inline-block px-2 py-1 bg-blue-600 text-white text-xs rounded-full mt-1">
{template.category}
</span>
)}
{template.description && (
<p className="text-gray-300 text-sm mt-2">{template.description}</p>
)}
</div>
<div className="flex space-x-2">
{onTemplateSelect && (
<button
onClick={() => onTemplateSelect(template.id)}
className={`px-3 py-1 rounded text-xs transition-colors ${
selectedTemplateId === template.id
? 'bg-green-600 text-white'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{selectedTemplateId === template.id ? 'Selected' : 'Select'}
</button>
)}
<button
onClick={() => handleEdit(template)}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(template.id)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs transition-colors"
>
Delete
</button>
</div>
</div>
<div className="bg-gray-600 p-3 rounded text-gray-200 text-sm whitespace-pre-wrap">
{template.templateText}
</div>
</div>
))
)}
</div>
</div>
);
}
+92 -83
View File
@@ -31,7 +31,7 @@ export default function Profile() {
const docSnap = await getDoc(docRef); const docSnap = await getDoc(docRef);
if (docSnap.exists()) { if (docSnap.exists()) {
setProfile(docSnap.data() as UserProfile); setProfile(docSnap.data() as UserProfile);
setNotificationsEnabled(docSnap.data().notificationsEnabled); setNotificationsEnabled(docSnap.data().notificationsEnabled ?? false);
} }
} catch (error) { } catch (error) {
console.error('Error loading profile:', error); console.error('Error loading profile:', error);
@@ -44,13 +44,6 @@ export default function Profile() {
} }
}, [user, loadProfile]); }, [user, loadProfile]);
// Load notification settings
useEffect(() => {
if (profile.notificationsEnabled !== undefined) {
setNotificationsEnabled(profile.notificationsEnabled);
}
}, [profile.notificationsEnabled]);
// Listen for FCM messages // Listen for FCM messages
useEffect(() => { useEffect(() => {
if (notificationsEnabled) { if (notificationsEnabled) {
@@ -146,88 +139,104 @@ export default function Profile() {
}; };
return ( return (
<div className="max-w-md mx-auto mb-8 p-6 bg-gray-800 rounded-lg"> <div className="max-w-2xl mx-auto p-6 space-y-8">
<h2 className="text-2xl font-bold mb-6">Profile</h2> <div className="text-center">
<h1 className="text-3xl font-bold text-white mb-2">Profile Settings</h1>
{/* Avatar */} <p className="text-gray-400">Manage your account and preferences</p>
<div className="mb-6">
<label className="block mb-2 text-lg">Avatar</label>
{profile.avatarUrl && (
<Image src={profile.avatarUrl} alt="Avatar" width={80} height={80} 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> </div>
{/* Theme */} {/* Avatar Section */}
<div className="mb-6"> <div className="bg-gray-800 p-6 rounded-lg shadow-lg">
<label className="block mb-2 text-lg">Theme</label> <h2 className="text-xl font-semibold mb-4 text-white">Profile Picture</h2>
<select <div className="flex items-center space-x-4">
value={theme} {profile.avatarUrl ? (
onChange={(e) => handleThemeChange(e.target.value)} <Image src={profile.avatarUrl} alt="Avatar" width={80} height={80} className="w-20 h-20 rounded-full border-2 border-gray-600" />
className="w-full p-3 bg-gray-700 border border-gray-600 rounded" ) : (
> <div className="w-20 h-20 rounded-full bg-gray-600 flex items-center justify-center text-gray-400">No Image</div>
<option value="dark">Dark</option> )}
<option value="light">Light</option> <div className="flex-1">
<option value="system">System</option> <input
</select> type="file"
</div> accept="image/*"
onChange={handleAvatarUpload}
{/* Notifications */} disabled={uploading}
<div className="mb-6"> className="block w-full text-sm text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700 disabled:opacity-50"
<label className="block mb-2 text-lg">Notifications</label> />
<div className="flex items-center mb-4"> {uploading && <p className="text-sm text-blue-400 mt-2">Uploading...</p>}
<input </div>
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> </div>
<p className="text-sm text-gray-400">
Get notified about your prayer schedule and when prayers are answered.
</p>
</div> </div>
{/* Categories */} {/* Theme Section */}
<div className="mb-6"> <div className="bg-gray-800 p-6 rounded-lg shadow-lg">
<label className="block mb-2 text-lg">Prayer Categories</label> <h2 className="text-xl font-semibold mb-4 text-white">Appearance</h2>
<div className="flex gap-2 mb-2"> <div>
<input <label className="block text-sm font-medium text-gray-300 mb-2">Theme</label>
type="text" <select
value={newCategory} value={theme}
onChange={(e) => setNewCategory(e.target.value)} onChange={(e) => handleThemeChange(e.target.value)}
placeholder="Add category" className="w-full p-3 bg-gray-700 border border-gray-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
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 <option value="dark">Dark</option>
</button> <option value="light">Light</option>
<option value="system">System</option>
</select>
</div> </div>
<div className="flex flex-wrap gap-2"> </div>
{(profile.categories || []).map(category => (
<span key={category} className="bg-gray-700 px-3 py-1 rounded flex items-center gap-2"> {/* Notifications Section */}
{category} <div className="bg-gray-800 p-6 rounded-lg shadow-lg">
<button <h2 className="text-xl font-semibold mb-4 text-white">Notifications</h2>
onClick={() => removeCategory(category)} <div className="space-y-3">
className="text-red-400 hover:text-red-300" <div className="flex items-center">
> <input
× type="checkbox"
</button> checked={notificationsEnabled}
</span> onChange={(e) => handleNotificationToggle(e.target.checked)}
))} className="w-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
<label className="ml-3 text-sm font-medium text-gray-300">
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>
</div>
{/* Categories Section */}
<div className="bg-gray-800 p-6 rounded-lg shadow-lg">
<h2 className="text-xl font-semibold mb-4 text-white">Prayer Categories</h2>
<div className="space-y-4">
<div className="flex space-x-2">
<input
type="text"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
placeholder="Add a new category"
className="flex-1 p-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
onClick={addCategory}
className="px-6 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
Add
</button>
</div>
<div className="flex flex-wrap gap-2">
{(profile.categories || []).map(category => (
<span key={category} className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-700 text-gray-300">
{category}
<button
onClick={() => removeCategory(category)}
className="ml-2 text-red-400 hover:text-red-300 transition-colors"
>
×
</button>
</span>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
+74
View File
@@ -0,0 +1,74 @@
import React from 'react';
interface Prayer {
id: string;
text: string;
type: 'prayer' | 'praise';
}
interface SharePrayerProps {
prayer: Prayer;
}
export default function SharePrayer({ prayer }: SharePrayerProps) {
const shareText = `🙏 ${prayer.type === 'prayer' ? 'Prayer' : 'Praise'}: ${prayer.text.substring(0, 100)}${prayer.text.length > 100 ? '...' : ''}`;
const shareUrl = window.location.href;
const shareToTwitter = () => {
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
};
const shareToFacebook = () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}&quote=${encodeURIComponent(shareText)}`;
window.open(url, '_blank', 'width=600,height=400');
};
const shareToWhatsApp = () => {
const url = `https://wa.me/?text=${encodeURIComponent(shareText + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(`${shareText} ${shareUrl}`);
alert('Prayer copied to clipboard!');
} catch (err) {
console.error('Failed to copy: ', err);
}
};
return (
<div className="flex items-center space-x-2 mt-2">
<span className="text-xs text-gray-400">Share:</span>
<button
onClick={shareToTwitter}
className="text-blue-400 hover:text-blue-300 text-sm px-2 py-1 rounded hover:bg-blue-400/10 transition-colors"
title="Share on Twitter"
>
🐦
</button>
<button
onClick={shareToFacebook}
className="text-blue-600 hover:text-blue-500 text-sm px-2 py-1 rounded hover:bg-blue-600/10 transition-colors"
title="Share on Facebook"
>
📘
</button>
<button
onClick={shareToWhatsApp}
className="text-green-400 hover:text-green-300 text-sm px-2 py-1 rounded hover:bg-green-400/10 transition-colors"
title="Share on WhatsApp"
>
💬
</button>
<button
onClick={copyToClipboard}
className="text-gray-400 hover:text-gray-300 text-sm px-2 py-1 rounded hover:bg-gray-400/10 transition-colors"
title="Copy to clipboard"
>
📋
</button>
</div>
);
}
+9 -7
View File
@@ -39,14 +39,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const unsubscribe = onAuthStateChanged(auth, async (user) => { const unsubscribe = onAuthStateChanged(auth, async (user) => {
setUser(user); setUser(user);
if (user) { if (user) {
// Request FCM token and save to Firestore // Request FCM token and save to Firestore only if permission granted
try { if (Notification.permission === 'granted') {
const token = await getToken(messaging, { vapidKey: 'YOUR_VAPID_KEY' }); // Replace with actual VAPID key try {
if (token) { const token = await getToken(messaging, { vapidKey: 'YOUR_VAPID_KEY' }); // Replace with actual VAPID key
await setDoc(doc(db, 'users', user.uid), { fcmToken: token }, { merge: true }); if (token) {
await setDoc(doc(db, 'users', user.uid), { fcmToken: token }, { merge: true });
}
} catch (error) {
console.error('Error getting FCM token:', error);
} }
} catch (error) {
console.error('Error getting FCM token:', error);
} }
// Handle foreground messages // Handle foreground messages
+1 -1
View File
@@ -1,6 +1,6 @@
import * as admin from 'firebase-admin'; import * as admin from 'firebase-admin';
if (!admin.apps.length && process.env.FIREBASE_PROJECT_ID) { if (!admin.apps.length) {
admin.initializeApp({ admin.initializeApp({
credential: admin.credential.cert({ credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID, projectId: process.env.FIREBASE_PROJECT_ID,
+69
View File
@@ -0,0 +1,69 @@
// Test script to send an email using your configured SMTP settings
// Run with: node test-email.js [email-address]
import fetch from 'node-fetch';
const testEmail = async (testEmailAddress) => {
// Use provided email or default
const email = testEmailAddress || 'nate.emmert@necloud.us';
const payload = {
email: email,
subject: 'Test Email from Prayer App',
message: `Hello!
This is a test email from your Prayer App.
Test Details:
- Type: Test Email
- Date: ${new Date().toLocaleDateString()}
- Time: ${new Date().toLocaleTimeString()}
- Test Email: ${email}
If you received this email, your SMTP settings are configured correctly!`
};
console.log('📧 Sending test email to:', payload.email);
console.log('📧 Subject:', payload.subject);
try {
const response = await fetch('http://localhost:3000/api/send-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const responseText = await response.text();
console.log('📧 Response status:', response.status);
if (response.ok) {
console.log('✅ Email sent successfully!');
console.log('📧 Response:', responseText);
} else {
console.error('❌ Failed to send email');
console.error('📧 Response:', responseText);
// Try to parse as JSON for better error messages
try {
const errorData = JSON.parse(responseText);
if (errorData.error) {
console.error('📧 Error details:', errorData.error);
if (errorData.error.includes('SMTP configuration not found')) {
console.log('\n💡 Solution: Run "node setup-smtp.js" to configure SMTP settings');
}
}
} catch (parseError) {
// Response wasn't JSON, already logged above
}
}
} catch (error) {
console.error('❌ Error sending email:', error.message);
}
};
// Get email from command line argument or use default
const emailArg = process.argv[2];
testEmail(emailArg);