Implement comprehensive enhancements: push notifications, export functionality, user profiles, community sharing, offline improvements, analytics, accessibility, testing, and documentation
This commit is contained in:
1 parent
1932dbe1fc
commit
3d8ba5b8f0
21 files changed
+6262
-2574
No files matched your search
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
@@ -26,6 +26,11 @@ A Progressive Web App (PWA) for journaling prayers and praises, organized by dat
|
||||
- Engaging loading screen with animations and inspirational content (minimum 5-second display)
|
||||
- Enhanced bottom navigation with gradients, shadows, and improved touch targets
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET/POST /api/send-email` - Send a single email reminder
|
||||
- `GET/POST /api/send-scheduled-emails` - Send scheduled email reminders to all users
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Firebase project at https://console.firebase.google.com/
|
||||
@@ -109,6 +114,53 @@ After deploying Firestore rules, you can configure SMTP settings through the adm
|
||||
|
||||
**Note:** For Gmail, you'll need to generate an "App Password" in your Google Account settings.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Firebase Configuration**: Ensure all Firebase config values are correctly set in `src/lib/firebase.ts`.
|
||||
|
||||
2. **SMTP Settings**: Verify SMTP credentials in the Admin panel. Use app passwords for Gmail.
|
||||
|
||||
3. **PWA Installation**: Clear browser cache if PWA doesn't install properly.
|
||||
|
||||
4. **Email Reminders**: Check Firestore security rules and ensure SMTP is configured.
|
||||
|
||||
### Development
|
||||
|
||||
- Run `npm run dev` for development server
|
||||
- Run `npm run build` for production build
|
||||
- Run `npm run lint` for code linting
|
||||
- Run `npm test` for running tests
|
||||
|
||||
## User Guides
|
||||
|
||||
### Adding Prayers
|
||||
|
||||
1. Click the "Add" tab
|
||||
2. Fill in the date, type (prayer/praise), text, and optional journal
|
||||
3. Set reminder frequency
|
||||
4. Click "Add Prayer"
|
||||
|
||||
### Managing Prayers
|
||||
|
||||
- **Active**: View current prayers
|
||||
- **Archived**: View archived prayers
|
||||
- **Search**: Search by content
|
||||
- **Community**: View shared prayers
|
||||
|
||||
### Profile Customization
|
||||
|
||||
- Click "Profile" to update avatar, theme, and categories
|
||||
|
||||
### Admin Features
|
||||
|
||||
- Configure SMTP for emails
|
||||
- Manage admin users
|
||||
- View analytics
|
||||
|
||||
## Getting Started
|
||||
|
||||
## Email Configuration
|
||||
|
||||
### Option 1: EmailJS (Free, Recommended)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
To: {{to_email}}
|
||||
From: {{from_name}}
|
||||
Subject: {{subject}}
|
||||
|
||||
{{message}}
|
||||
|
||||
Prayer Details:
|
||||
- Type: {{prayer_type}}
|
||||
- Date: {{prayer_date}}
|
||||
- Text: {{prayer_text}}
|
||||
- Journal: {{prayer_journal}}
|
||||
- Praying for: {{praying_for}}
|
||||
- Reminder frequency: {{reminder_frequency}}
|
||||
@@ -0,0 +1,19 @@
|
||||
const nextJest = require('next/jest')
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||
dir: './',
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapping: {
|
||||
// Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths)
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'es', 'fr'], // Add more as needed
|
||||
},
|
||||
};
|
||||
Generated
+5609
-10
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -6,26 +6,39 @@
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dataconnect/generated": "file:src/dataconnect-generated",
|
||||
"@emailjs/browser": "^4.4.1",
|
||||
"firebase": "^12.4.0",
|
||||
"firebase-admin": "^13.5.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"next": "16.0.0",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.9",
|
||||
"react": "19.2.0",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Import Firebase scripts
|
||||
importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-messaging-compat.js');
|
||||
|
||||
// Your web app's Firebase configuration
|
||||
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"
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
// Handle background messages
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
console.log('Received background message ', payload);
|
||||
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192x192.png' // Update with your app icon
|
||||
};
|
||||
|
||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prayer": "Prayer",
|
||||
"praise": "Praise",
|
||||
"add": "Add",
|
||||
"active": "Active",
|
||||
"archived": "Archived",
|
||||
"search": "Search",
|
||||
"community": "Community",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout",
|
||||
"share": "Share",
|
||||
"export": "Export"
|
||||
}
|
||||
+1
-100
@@ -1,100 +1 @@
|
||||
/**
|
||||
* 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');
|
||||
|
||||
}));
|
||||
if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(a,t)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let c={};const f=e=>n(e,i),r={module:{uri:i},exports:c,require:f};s[i]=Promise.all(a.map(e=>r[e]||f(e))).then(e=>(t(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/Npzhh3skxJdtxDmKA4fgj/_buildManifest.js",revision:"a91cb247fd17096d2ba17603cfdf5fe7"},{url:"/_next/static/Npzhh3skxJdtxDmKA4fgj/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/104.b9111a893f0ad9d9.js",revision:"b9111a893f0ad9d9"},{url:"/_next/static/chunks/139.678015000317a168.js",revision:"678015000317a168"},{url:"/_next/static/chunks/164f4fb6-a2df26e501616e62.js",revision:"a2df26e501616e62"},{url:"/_next/static/chunks/199.6d8b2513760bb8a6.js",revision:"6d8b2513760bb8a6"},{url:"/_next/static/chunks/2f0b94e8-4e6d0ae97e147ff2.js",revision:"4e6d0ae97e147ff2"},{url:"/_next/static/chunks/31-fdb9a1b01d8fa576.js",revision:"fdb9a1b01d8fa576"},{url:"/_next/static/chunks/334-e8ac4aff101692e9.js",revision:"e8ac4aff101692e9"},{url:"/_next/static/chunks/4bd1b696-b7529f9b306ab05d.js",revision:"b7529f9b306ab05d"},{url:"/_next/static/chunks/512-8d2b9de6fda25175.js",revision:"8d2b9de6fda25175"},{url:"/_next/static/chunks/646.30443a55a43a3bfb.js",revision:"30443a55a43a3bfb"},{url:"/_next/static/chunks/7508b87c-4b79104cc2083662.js",revision:"4b79104cc2083662"},{url:"/_next/static/chunks/804.7f3d4655e2446c89.js",revision:"7f3d4655e2446c89"},{url:"/_next/static/chunks/931.6ff3e03b1cc8cdf1.js",revision:"6ff3e03b1cc8cdf1"},{url:"/_next/static/chunks/995.a093d11da39e25d3.js",revision:"a093d11da39e25d3"},{url:"/_next/static/chunks/ad2866b8.f49c70da33da3b7b.js",revision:"f49c70da33da3b7b"},{url:"/_next/static/chunks/app/_global-error/page-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/app/_not-found/page-1123cfe416c748d0.js",revision:"1123cfe416c748d0"},{url:"/_next/static/chunks/app/api/send-email/route-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/app/api/send-scheduled-emails/route-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/app/layout-27764907da72f59f.js",revision:"27764907da72f59f"},{url:"/_next/static/chunks/app/page-d005950c551314c5.js",revision:"d005950c551314c5"},{url:"/_next/static/chunks/bc98253f.ac7fe72f9e73e374.js",revision:"ac7fe72f9e73e374"},{url:"/_next/static/chunks/dd8162e8-bea2cd35813297c3.js",revision:"bea2cd35813297c3"},{url:"/_next/static/chunks/framework-292291387d6b2e39.js",revision:"292291387d6b2e39"},{url:"/_next/static/chunks/main-7ddb690de3906442.js",revision:"7ddb690de3906442"},{url:"/_next/static/chunks/main-app-4f19d69ed4295336.js",revision:"4f19d69ed4295336"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-bcba0732671665d2.js",revision:"bcba0732671665d2"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-7ff1880bba064291.js",revision:"7ff1880bba064291"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-a53d769f9dbc9071.js",revision:"a53d769f9dbc9071"},{url:"/_next/static/css/ae12300f4bd2cede.css",revision:"ae12300f4bd2cede"},{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:"/locales/en/common.json",revision:"54fe8b7b3b2f39e7604b236e7a480251"},{url:"/manifest.json",revision:"785fbc5941efd3caa27ade80c67bb5ef"},{url:"/next.svg",revisLine truncated
|
||||
@@ -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
@@ -0,0 +1,7 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import Profile from '../components/Profile';
|
||||
|
||||
test('renders profile component', () => {
|
||||
render(<Profile />);
|
||||
expect(screen.getByText('Profile')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { doc, getDoc, collection, getDocs, updateDoc } from 'firebase/firestore';
|
||||
import { db } from '@/lib/firebase';
|
||||
import { admin } from '@/lib/firebase-admin';
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
@@ -327,6 +328,22 @@ export async function GET() {
|
||||
html: generatePrayerEmailHTML(message, subject),
|
||||
});
|
||||
|
||||
// Send push notification if FCM token exists
|
||||
if (userData.fcmToken && admin.apps.length > 0) {
|
||||
try {
|
||||
await admin.messaging().send({
|
||||
token: userData.fcmToken,
|
||||
notification: {
|
||||
title: subject,
|
||||
body: `You have ${userPrayers.length} active prayers to review.`,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Sent push notification to ${userData.email}`);
|
||||
} catch (fcmError) {
|
||||
console.error(`❌ Failed to send FCM to ${userData.email}:`, fcmError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last email sent time
|
||||
await updateDoc(doc(db, 'users', userId), {
|
||||
lastEmailSent: new Date(),
|
||||
|
||||
+173
-5
@@ -4,9 +4,12 @@ import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import Login from '@/components/Login';
|
||||
import Admin from '@/components/Admin';
|
||||
import Profile from '@/components/Profile';
|
||||
import { db } from '@/lib/firebase';
|
||||
import { doc, setDoc, collection, addDoc, getDocs, updateDoc, query, where } from 'firebase/firestore';
|
||||
import emailjs from '@emailjs/browser';
|
||||
import jsPDF from 'jspdf';
|
||||
import { CSVLink } from 'react-csv';
|
||||
|
||||
interface Prayer {
|
||||
id: string;
|
||||
@@ -24,6 +27,7 @@ interface Prayer {
|
||||
|
||||
export default function Home() {
|
||||
const [prayers, setPrayers] = useState<Prayer[]>([]);
|
||||
const [sharedPrayers, setSharedPrayers] = useState<Prayer[]>([]);
|
||||
const [form, setForm] = useState({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
type: 'prayer' as 'prayer' | 'praise',
|
||||
@@ -35,9 +39,10 @@ export default function Home() {
|
||||
includeActiveSummary: false,
|
||||
});
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState<'add' | 'active' | 'archived' | 'search'>('add');
|
||||
const [activeTab, setActiveTab] = useState<'add' | 'active' | 'archived' | 'search' | 'community'>('add');
|
||||
const itemsPerPage = 10;
|
||||
const { user, loading, logout, isAdmin } = useAuth();
|
||||
const [showLoadingScreen, setShowLoadingScreen] = useState(true);
|
||||
@@ -71,6 +76,23 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
// Load shared prayers
|
||||
const loadSharedPrayers = async () => {
|
||||
try {
|
||||
const sharedRef = collection(db, 'sharedPrayers');
|
||||
const querySnapshot = await getDocs(sharedRef);
|
||||
|
||||
const loadedShared: Prayer[] = [];
|
||||
querySnapshot.forEach((doc) => {
|
||||
loadedShared.push({ id: doc.id, ...doc.data() } as Prayer);
|
||||
});
|
||||
|
||||
setSharedPrayers(loadedShared);
|
||||
} catch (error) {
|
||||
console.error('Error loading shared prayers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Load prayers when user changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -80,6 +102,58 @@ export default function Home() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Load shared prayers on mount
|
||||
useEffect(() => {
|
||||
loadSharedPrayers();
|
||||
}, []);
|
||||
|
||||
// Export functions
|
||||
const exportToPDF = (prayerList: Prayer[], title: string) => {
|
||||
const doc = new jsPDF();
|
||||
doc.text(title, 10, 10);
|
||||
let y = 20;
|
||||
prayerList.forEach((prayer, index) => {
|
||||
if (y > 270) {
|
||||
doc.addPage();
|
||||
y = 10;
|
||||
}
|
||||
doc.text(`${index + 1}. ${prayer.date} - ${prayer.type.toUpperCase()}: ${prayer.text}`, 10, y);
|
||||
y += 10;
|
||||
if (prayer.prayFor) {
|
||||
doc.text(`Praying for: ${prayer.prayFor}`, 10, y);
|
||||
y += 10;
|
||||
}
|
||||
if (prayer.journal) {
|
||||
doc.text(`Journal: ${prayer.journal}`, 10, y);
|
||||
y += 10;
|
||||
}
|
||||
y += 5;
|
||||
});
|
||||
doc.save(`${title.replace(/\s+/g, '_')}.pdf`);
|
||||
};
|
||||
|
||||
const exportToJSON = (prayerList: Prayer[], title: string) => {
|
||||
const dataStr = JSON.stringify(prayerList, null, 2);
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
||||
const exportFileDefaultName = `${title.replace(/\s+/g, '_')}.json`;
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.setAttribute('href', dataUri);
|
||||
linkElement.setAttribute('download', exportFileDefaultName);
|
||||
linkElement.click();
|
||||
};
|
||||
|
||||
const getCSVData = (prayerList: Prayer[]) => {
|
||||
return prayerList.map(prayer => ({
|
||||
Date: prayer.date,
|
||||
Type: prayer.type,
|
||||
Text: prayer.text,
|
||||
PrayFor: prayer.prayFor,
|
||||
Journal: prayer.journal,
|
||||
Email: prayer.email,
|
||||
Archived: prayer.archived ? 'Yes' : 'No'
|
||||
}));
|
||||
};
|
||||
|
||||
// Auto-populate email field with user's email
|
||||
useEffect(() => {
|
||||
if (user?.email) {
|
||||
@@ -87,6 +161,23 @@ export default function Home() {
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Load draft from localStorage
|
||||
useEffect(() => {
|
||||
const draft = localStorage.getItem('prayerDraft');
|
||||
if (draft) {
|
||||
try {
|
||||
setForm(JSON.parse(draft));
|
||||
} catch (e) {
|
||||
console.error('Error loading draft:', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save draft to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('prayerDraft', JSON.stringify(form));
|
||||
}, [form]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -229,10 +320,12 @@ export default function Home() {
|
||||
|
||||
const currentPrayers = activeTab === 'active' ? paginatedActivePrayers :
|
||||
activeTab === 'archived' ? paginatedArchivedPrayers :
|
||||
paginatedSearchPrayers;
|
||||
activeTab === 'search' ? paginatedSearchPrayers :
|
||||
sharedPrayers; // Community
|
||||
const currentTotalPages = activeTab === 'active' ? totalActivePages :
|
||||
activeTab === 'archived' ? totalArchivedPages :
|
||||
totalSearchPages;
|
||||
activeTab === 'search' ? totalSearchPages :
|
||||
1; // Community
|
||||
|
||||
const groupedPrayers = currentPrayers.reduce((acc, prayer) => {
|
||||
if (!acc[prayer.date]) acc[prayer.date] = [];
|
||||
@@ -284,6 +377,20 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const shareToCommunity = async (prayer: Prayer) => {
|
||||
try {
|
||||
await addDoc(collection(db, 'sharedPrayers'), {
|
||||
...prayer,
|
||||
userId: null, // Anonymous
|
||||
sharedAt: new Date(),
|
||||
});
|
||||
loadSharedPrayers(); // Reload
|
||||
alert('Prayer shared to community!');
|
||||
} catch (error) {
|
||||
console.error('Error sharing to community:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Archive/unarchive prayers in Firestore
|
||||
const toggleArchivePrayer = async (prayerId: string, archived: boolean) => {
|
||||
try {
|
||||
@@ -368,6 +475,12 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowProfile(!showProfile)}
|
||||
className="px-4 py-3 bg-blue-600 hover:bg-blue-700 rounded text-base font-medium"
|
||||
>
|
||||
{showProfile ? 'Hide Profile' : 'Profile'}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setShowAdmin(!showAdmin)}
|
||||
@@ -383,6 +496,37 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{showAdmin && <Admin />}
|
||||
{showProfile && <Profile />}
|
||||
|
||||
{(activeTab === 'active' || activeTab === 'archived') && (
|
||||
<div className="max-w-md mx-auto mb-4 flex flex-wrap gap-2 justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
const prayerList = activeTab === 'active' ? prayers.filter(p => !p.archived) : prayers.filter(p => p.archived);
|
||||
exportToPDF(prayerList, `${activeTab} Prayers`);
|
||||
}}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-all duration-200"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
<CSVLink
|
||||
data={getCSVData(activeTab === 'active' ? prayers.filter(p => !p.archived) : prayers.filter(p => p.archived))}
|
||||
filename={`${activeTab}_prayers.csv`}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-all duration-200 inline-block"
|
||||
>
|
||||
Export CSV
|
||||
</CSVLink>
|
||||
<button
|
||||
onClick={() => {
|
||||
const prayerList = activeTab === 'active' ? prayers.filter(p => !p.archived) : prayers.filter(p => p.archived);
|
||||
exportToJSON(prayerList, `${activeTab} Prayers`);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-all duration-200"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'add' && (
|
||||
<form onSubmit={handleSubmit} className="max-w-md mx-auto mb-8 space-y-6">
|
||||
@@ -522,13 +666,22 @@ export default function Home() {
|
||||
<div key={prayer.id} className={`bg-gray-800 p-4 rounded mb-4 ${prayer.archived || activeTab === 'search' ? 'opacity-75 border border-gray-600' : ''}`}>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<span className="font-medium capitalize text-lg">{prayer.type}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => sharePrayer(prayer)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg flex items-center gap-2 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg flex items-center gap-2 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
title="Share this prayer"
|
||||
>
|
||||
📤 Share
|
||||
</button>
|
||||
<button
|
||||
onClick={() => shareToCommunity(prayer)}
|
||||
className="px-3 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg flex items-center gap-2 transition-all duration-200 shadow-md hover:shadow-lg"
|
||||
title="Share to community"
|
||||
>
|
||||
🌍 Community
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2">{prayer.text}</p>
|
||||
{prayer.journal && (
|
||||
@@ -596,7 +749,7 @@ export default function Home() {
|
||||
|
||||
{/* Bottom Navigation Tabs */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-gray-900 via-gray-900 to-gray-800 border-t border-gray-700 shadow-2xl">
|
||||
<div className="grid grid-cols-4 px-2 py-2">
|
||||
<div className="grid grid-cols-5 px-2 py-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('add');
|
||||
@@ -655,6 +808,21 @@ export default function Home() {
|
||||
<span className="text-lg mb-1">🔍</span>
|
||||
<span className="text-xs leading-tight">Search</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('community');
|
||||
setCurrentPage(1);
|
||||
setSearchTerm(''); // Clear search when switching to community
|
||||
}}
|
||||
className={`mx-1 py-3 px-2 rounded-xl text-center transition-all duration-200 text-sm font-semibold flex flex-col items-center justify-center min-h-[60px] ${
|
||||
activeTab === 'community'
|
||||
? 'bg-gradient-to-br from-teal-600 to-teal-700 text-white shadow-lg transform scale-105'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800/50 active:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg mb-1">🌍</span>
|
||||
<span className="text-xs leading-tight">Community</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { doc, getDoc, setDoc } from 'firebase/firestore';
|
||||
import { doc, getDoc, setDoc, collection, getDocs } from 'firebase/firestore';
|
||||
import { db } from '@/lib/firebase';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
@@ -35,6 +35,11 @@ export default function Admin() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [newAdminEmail, setNewAdminEmail] = useState('');
|
||||
const [metrics, setMetrics] = useState({
|
||||
totalUsers: 0,
|
||||
totalPrayers: 0,
|
||||
totalSharedPrayers: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
@@ -56,6 +61,18 @@ export default function Admin() {
|
||||
// Default admin config
|
||||
setAdminConfig({ adminEmails: ['your-admin-email@example.com'] });
|
||||
}
|
||||
|
||||
// Load metrics
|
||||
const usersSnapshot = await getDocs(collection(db, 'users'));
|
||||
const prayersSnapshot = await getDocs(collection(db, 'prayers'));
|
||||
const sharedPrayersSnapshot = await getDocs(collection(db, 'sharedPrayers'));
|
||||
|
||||
setMetrics({
|
||||
totalUsers: usersSnapshot.size,
|
||||
totalPrayers: prayersSnapshot.size,
|
||||
totalSharedPrayers: sharedPrayersSnapshot.size,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading config:', error);
|
||||
} finally {
|
||||
@@ -167,6 +184,24 @@ export default function Admin() {
|
||||
<div className="max-w-2xl mx-auto p-6 bg-gray-800 rounded-lg">
|
||||
<h2 className="text-2xl font-bold mb-6 text-white">Admin Settings</h2>
|
||||
|
||||
<div className="mb-6 p-4 bg-gray-700 rounded">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Analytics Dashboard</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">{metrics.totalUsers}</div>
|
||||
<div className="text-sm text-gray-300">Total Users</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-400">{metrics.totalPrayers}</div>
|
||||
<div className="text-sm text-gray-300">Total Prayers</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">{metrics.totalSharedPrayers}</div>
|
||||
<div className="text-sm text-gray-300">Shared Prayers</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">SMTP Configuration</h3>
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { storage, db } from '@/lib/firebase';
|
||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
import { doc, getDoc, updateDoc } from 'firebase/firestore';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
interface UserProfile {
|
||||
avatarUrl?: string;
|
||||
theme?: string;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [profile, setProfile] = useState<UserProfile>({});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [newCategory, setNewCategory] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadProfile();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const docRef = doc(db, 'users', user.uid);
|
||||
const docSnap = await getDoc(docRef);
|
||||
if (docSnap.exists()) {
|
||||
setProfile(docSnap.data() as UserProfile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !user) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const storageRef = ref(storage, `avatars/${user.uid}`);
|
||||
await uploadBytes(storageRef, file);
|
||||
const url = await getDownloadURL(storageRef);
|
||||
await updateDoc(doc(db, 'users', user.uid), { avatarUrl: url });
|
||||
setProfile(prev => ({ ...prev, avatarUrl: url }));
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = async (newTheme: string) => {
|
||||
setTheme(newTheme);
|
||||
if (user) {
|
||||
await updateDoc(doc(db, 'users', user.uid), { theme: newTheme });
|
||||
setProfile(prev => ({ ...prev, theme: newTheme }));
|
||||
}
|
||||
};
|
||||
|
||||
const addCategory = async () => {
|
||||
if (!newCategory.trim() || !user) return;
|
||||
const updatedCategories = [...(profile.categories || []), newCategory.trim()];
|
||||
await updateDoc(doc(db, 'users', user.uid), { categories: updatedCategories });
|
||||
setProfile(prev => ({ ...prev, categories: updatedCategories }));
|
||||
setNewCategory('');
|
||||
};
|
||||
|
||||
const removeCategory = async (category: string) => {
|
||||
if (!user) return;
|
||||
const updatedCategories = (profile.categories || []).filter(c => c !== category);
|
||||
await updateDoc(doc(db, 'users', user.uid), { categories: updatedCategories });
|
||||
setProfile(prev => ({ ...prev, categories: updatedCategories }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mb-8 p-6 bg-gray-800 rounded-lg">
|
||||
<h2 className="text-2xl font-bold mb-6">Profile</h2>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-6">
|
||||
<label className="block mb-2 text-lg">Avatar</label>
|
||||
{profile.avatarUrl && (
|
||||
<img src={profile.avatarUrl} alt="Avatar" className="w-20 h-20 rounded-full mb-2" />
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
disabled={uploading}
|
||||
className="w-full p-3 bg-gray-700 border border-gray-600 rounded"
|
||||
/>
|
||||
{uploading && <p className="text-sm text-gray-400 mt-1">Uploading...</p>}
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div className="mb-6">
|
||||
<label className="block mb-2 text-lg">Theme</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => handleThemeChange(e.target.value)}
|
||||
className="w-full p-3 bg-gray-700 border border-gray-600 rounded"
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="mb-6">
|
||||
<label className="block mb-2 text-lg">Prayer Categories</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
placeholder="Add category"
|
||||
className="flex-1 p-3 bg-gray-700 border border-gray-600 rounded"
|
||||
/>
|
||||
<button
|
||||
onClick={addCategory}
|
||||
className="px-4 py-3 bg-green-600 hover:bg-green-700 rounded"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(profile.categories || []).map(category => (
|
||||
<span key={category} className="bg-gray-700 px-3 py-1 rounded flex items-center gap-2">
|
||||
{category}
|
||||
<button
|
||||
onClick={() => removeCategory(category)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { User, signInWithEmailAndPassword, signOut, onAuthStateChanged, createUserWithEmailAndPassword } from 'firebase/auth';
|
||||
import { auth } from '@/lib/firebase';
|
||||
import { auth, db, app } from '@/lib/firebase';
|
||||
import { doc, setDoc } from 'firebase/firestore';
|
||||
import { getToken, onMessage } from 'firebase/messaging';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
@@ -20,11 +22,65 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||
// Register service worker for FCM
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/firebase-messaging-sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered for FCM');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
const initMessaging = async () => {
|
||||
const { getMessaging, getToken, onMessage } = await import('firebase/messaging');
|
||||
const messaging = getMessaging(app);
|
||||
|
||||
const unsubscribe = onAuthStateChanged(auth, async (user) => {
|
||||
setUser(user);
|
||||
if (user) {
|
||||
// Request FCM token and save to Firestore
|
||||
try {
|
||||
const token = await getToken(messaging, { vapidKey: 'YOUR_VAPID_KEY' }); // Replace with actual VAPID key
|
||||
if (token) {
|
||||
await setDoc(doc(db, 'users', user.uid), { fcmToken: token }, { merge: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting FCM token:', error);
|
||||
}
|
||||
|
||||
// Handle foreground messages
|
||||
onMessage(messaging, (payload) => {
|
||||
console.log('Message received. ', payload);
|
||||
// Show notification or alert
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(payload.notification?.title || 'Prayer Reminder', {
|
||||
body: payload.notification?.body,
|
||||
icon: '/icon-192x192.png'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
return unsubscribe;
|
||||
};
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
if (typeof window !== 'undefined') {
|
||||
initMessaging().then((unsub) => unsubscribe = unsub);
|
||||
} else {
|
||||
const unsub = onAuthStateChanged(auth, (user) => {
|
||||
setUser(user);
|
||||
setLoading(false);
|
||||
});
|
||||
unsubscribe = unsub;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Configure admin email addresses here - only these users will have admin access
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as admin from 'firebase-admin';
|
||||
|
||||
if (!admin.apps.length && process.env.FIREBASE_PROJECT_ID) {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export { admin };
|
||||
@@ -2,6 +2,8 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
import { getStorage } from 'firebase/storage';
|
||||
import { getAnalytics } from 'firebase/analytics';
|
||||
|
||||
// TODO: Add SDKs for Firebase products that you want to use
|
||||
// https://firebase.google.com/docs/web/setup#available-libraries
|
||||
@@ -24,3 +26,22 @@ export const auth = getAuth(app);
|
||||
|
||||
// Initialize Cloud Firestore and get a reference to the service
|
||||
export const db = getFirestore(app);
|
||||
|
||||
// Initialize Firebase Storage and get a reference to the service
|
||||
export const storage = getStorage(app);
|
||||
|
||||
// Initialize Firebase Analytics and get a reference to the service
|
||||
let analytics: any = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
import('firebase/analytics').then(({ getAnalytics, isSupported }) => {
|
||||
isSupported().then((supported) => {
|
||||
if (supported) {
|
||||
analytics = getAnalytics(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { analytics };
|
||||
|
||||
export { app };
|
||||
Reference in new issue
Block a user