Compare commits

...
127 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 4.6 8add7ddc10 Update README for v0.4.0 and Gitea migration
Rewrite to accurately reflect current stack (Next.js + Express/JSON server),
Docker deployment, ntfy push notifications, and Gitea CI setup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 14:37:56 -04:00
nmemmert eb6cbcc3d4 Merge main into master, bring in Gitea workflow 2026-08-26 14:35:19 -04:00
nmemmertandClaude Sonnet 4.6 4426b0a380 Migrate workflow to Gitea Actions
Build & Publish Docker Image / build-and-push (push) Successful in 5m42s
Move .github/workflows/ → .gitea/workflows/ and update secrets
to use REGISTRY_USER/REGISTRY_TOKEN with git.necloud.us registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 14:31:55 -04:00
Claude 26ef999c17 fix: replace em dash in ntfy title headers with ASCII dash
HTTP headers only accept bytes 0-255; the em dash (U+2014) caused
'Cannot convert argument to ByteString' errors. Replace non-ASCII
chars in the title header and use plain hyphens in title strings.
2026-08-02 19:54:51 +00:00
Claude 2f445bdc91 docs: clarify ntfy requires no account, note unverified commits 2026-08-02 17:52:49 +00:00
Claude ce62493057 docs: update README and compose for notifications and timezone
- README: document ntfy push notifications setup, per-prayer reminders,
  self-hosted ntfy, updated feature list and stack table
- docker-compose.yml: add TZ environment variable with instructions
  so daily digest and reminder times fire at the correct local time
2026-08-02 17:48:55 +00:00
Claude 965c47d66a feat: add ntfy push notifications for daily digest and per-prayer reminders
- Server-side cron (node-cron) checks every minute for scheduled notifications
- Daily digest: sends all active prayers as a summary at a configured time
- Per-prayer reminders: pick days of the week per prayer; fires at configured time
- Settings panel in UI to configure ntfy topic, digest time, reminder time
- Test notification button to verify ntfy is working
- Settings stored in data/settings.json (persisted alongside prayers)
2026-08-02 14:45:18 +00:00
nmemmertandClaude Sonnet 4.6 13c00e9322 Remove VOLUME instruction from Dockerfile
VOLUME ["/data"] was causing Docker to create an anonymous volume for
/data that shadowed the bind mount defined in docker-compose.yml,
leaving the container with an empty /data instead of the host data.
The bind mount in docker-compose.yml is sufficient; no Dockerfile
VOLUME needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:33:20 -04:00
nmemmertandClaude Sonnet 4.6 a35ba756ca Fix prod image: move build deps to devDependencies, restore proper npm install
The production stage was using an ad-hoc npm install without package.json,
which could pull unexpected package versions and lost the lockfile guarantee.
Moved all React/build/test/type packages to devDependencies so the production
stage can use npm install --omit=dev cleanly, installing only express, cors,
and docx — exactly what server.js needs. Regenerated lockfile to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 10:01:38 -04:00
nmemmertandClaude Sonnet 4.6 c2459c000e Fix build stage: use npm install instead of npm ci
npm ci fails when the lockfile was generated on a different platform
or npm version than what runs inside the container. npm install is
more forgiving and resolves the exit code 1 in the build stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:07:34 -04:00
nmemmertandClaude Sonnet 4.6 daa7eb1a0d Fix Docker build: upgrade to node:22-alpine, install only server deps
The production stage was running npm ci --omit=dev against a macOS-
generated lockfile on linux/alpine, which caused exit code 1. Switch
to installing only the three packages the server actually needs
(express, cors, docx) so the lockfile is irrelevant in that stage.
Also upgrades to node:22-alpine (current LTS) to clear the Node 20
deprecation warning on GitHub Actions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:04:39 -04:00
nmemmertandClaude Sonnet 4.6 96c4b541f9 Add color-coded calendar dots, prayer notes, and export prayer log
- Each prayer gets a consistent color derived from its ID; calendar
  dots now use those colors so you can see which prayers were covered
  at a glance, and the day-detail panel color-codes each entry too
- New Notes field on every prayer card for scripture, updates, and
  mid-prayer thoughts — separate from the answer field, freely editable
- Word export now includes Notes and Prayer Log sections for each prayer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 08:39:16 -04:00
nmemmert 5da449e6ef v0.4.0: Dashboard navigation, enhanced Today's Prayers, fresh compose form 2026-01-19 12:23:55 -05:00
nmemmert c36c3b1c38 Bump version to 0.3.0 and update what's new 2026-01-19 10:16:45 -05:00
nmemmert 497af40398 0.3.0 2026-01-19 10:14:52 -05:00
nmemmert 67c5ff1b35 Major improvements: Firebase data migration, enhanced loading screens, multi-day prayer tracking
- Migrated all localStorage data to Firebase for cross-device sync
  - Prayer drafts now stored in users/{userId}/drafts
  - Reminder settings in users/{userId}/settings
  - Prayer statistics in users/{userId}/statistics
- Implemented persistent 'most prayed for' tracking in Firebase
- Enhanced loading screen with animated backgrounds and gradients
- Fixed auth session restoration on hard refresh
- Added prayedDates array for multi-day prayer tracking
- Calendar now accurately shows all days a prayer was prayed for
- Enabled Firestore offline persistence for offline-first experience
- Updated Firestore security rules for new data structure
- Improved clock display (removed leading zero from hours)
- Moved clock below date on homepage with appropriate sizing
- Fixed data loading race conditions between auth and Firestore
2026-01-19 10:11:57 -05:00
nmemmert 2c29fc28d6 chore: update app icons to new prayer journal design
Replace old praying hands icon with new prayer journal on cloud icon (192x192 and 512x512 PNG files)

Completes visual branding refresh across all screens
2026-01-19 09:29:06 -05:00
nmemmert aa3a35bc4a feat: add app icon branding to all remaining page headers
- CalendarView header now displays icon
- ReflectionsList header now displays icon
- AnsweredPrayers header now displays icon
- ThingsToPrayFor header now displays icon

Completes visual identity unification across all main screens
2026-01-19 09:10:39 -05:00
nmemmert 74686cece1 chore: remove legacy routes and email APIs 2026-01-19 09:05:31 -05:00
nmemmert e53e8088bb Unify nav styling; replace remaining emojis with Lucide icons (cards + loader); add brand icon in top nav 2026-01-19 08:32:00 -05:00
nmemmert 46fa6097a1 Unify gradients and text: standardize app/page.tsx backgrounds; set Clock to white text for dark theme consistency 2026-01-19 08:29:44 -05:00
nmemmert e5aa9b3e37 BottomNav: replace emoji icons with Lucide, unify icon sizes and actions 2026-01-19 08:24:10 -05:00
nmemmert e2add8223c Replace emoji headings with Lucide icons; unify heading icon styles across pages 2026-01-19 08:23:08 -05:00
nmemmert d760b34ba4 Standardize design across all pages - consistent dark gradient theme, typography, and card styling 2026-01-19 08:20:19 -05:00
nmemmert 6a9237d76d Fix top navigation overlap - standardize pt-24 padding on all pages 2026-01-19 08:16:52 -05:00
nmemmert 7a96380ae9 Replace ClientLayout with inline providers in layout.tsx 2026-01-19 08:12:46 -05:00
nmemmert 9fc41fffb9 Remove unused components from redesign - Admin, ClientLayout, EmailTemplatesManager, LoadingSkeleton, Navbar, PrayerChain, PrayerGoalsManager, PrayerTemplatesManager, Profile, SharePrayer 2026-01-19 08:11:21 -05:00
nmemmert b2cea9c0a3 Fix Firestore undefined value error when creating prayers 2026-01-17 15:17:31 -05:00
nmemmert a1236c173d Remove email verification requirement for adding prayer entries 2026-01-17 13:40:45 -05:00
nmemmert 3c5da024b9 Remove email verification requirement 2026-01-16 11:46:35 -05:00
nmemmert ed526b39b1 Update navigation for iPhone safe areas 2026-01-16 11:19:43 -05:00
nmemmert d6f4576105 Fix safe area insets for iPhone using CSS custom properties 2026-01-16 11:14:29 -05:00
nmemmert 61ecd7679a Add safe area support for devices with rounded corners and home indicators 2026-01-16 11:09:15 -05:00
nmemmert 0704984a3c Add cross-platform prayer reminder notifications with ReminderBanner component 2026-01-16 10:58:27 -05:00
nmemmert 618ab3a756 feat: cross-platform prayer reminders (iOS, Android, Windows, Linux)
- Detect platform and disable Web Push for iOS (Safari limitation)
- For iOS: Enable in-app reminders instead of push notifications
- Add platform-specific UI messaging
- Add test notification button for debugging
- Track last reminder date in localStorage
- Fix notification permission checks
- Support all platforms: iOS (in-app), Android/Windows/Linux (push + in-app)
2026-01-16 10:35:37 -05:00
nmemmert a3de1aaa1a feat: add daily prayer reminder push notifications with time picker 2026-01-16 10:07:45 -05:00
nmemmert 49d1bdb47d feat: show different colors in Journal calendar for prayer entries vs prayed for activities 2026-01-16 10:03:13 -05:00
nmemmert ee1daaca25 fix: persist verification banner state across re-renders using sessionStorage 2026-01-16 09:57:18 -05:00
nmemmert 5e3a159637 fix: show verification banner for auth/invalid-credential errors 2026-01-16 09:53:14 -05:00
nmemmert 90adc2eb32 fix: prevent Firestore access for unverified users and add password prompt for resend 2026-01-16 09:50:35 -05:00
nmemmert 81beeeee70 fix: make verification error detection case-insensitive 2026-01-16 09:47:45 -05:00
nmemmert 3de069fce9 feat: enhance unverified user banner when returning to login 2026-01-16 09:45:10 -05:00
nmemmert 40b61243c1 feat: enhance verification banner with clear messaging and resend button 2026-01-16 09:43:30 -05:00
nmemmert aacb2813d6 fix: sign out user immediately after signup to prevent Firestore access before verification 2026-01-16 09:42:03 -05:00
nmemmert 302a89e8d8 feat: add resend verification button on login screen for unverified users 2026-01-16 09:39:56 -05:00
nmemmert 7c47901563 feat: add email verification status and resend button in About page 2026-01-16 09:37:08 -05:00
nmemmert 74ee458ba9 chore: add measurementId to Firebase config 2026-01-16 09:27:05 -05:00
nmemmert 964e7c8aff feat: require email verification before login 2026-01-16 09:25:48 -05:00
nmemmert acdc219ad3 fix: resolve email verification timing issue by sending to newly created user 2026-01-16 09:23:45 -05:00
nmemmert 825156a53a feat: add email verification after signup and password reset functionality 2026-01-16 09:21:04 -05:00
nmemmert 5b1124cd85 feat: add first name capture at signup and multi-step delete account confirmation 2026-01-16 09:14:13 -05:00
nmemmert 0cc08f7c26 feat: always show Most Prayed For box with motivational message when empty 2026-01-16 09:07:21 -05:00
nmemmert a3bdb11ad7 feat: add Remember Me checkbox with 15-day auto-logout 2026-01-16 09:00:06 -05:00
nmemmert 52fd7e7c00 fix: update login screen to show Haver app name 2026-01-16 08:58:08 -05:00
nmemmert 649daa3dda fix: calculate Most Prayed For based on Mark Prayed clicks, not prayer count 2026-01-16 08:56:23 -05:00
nmemmert 8b4109a8fd feat: show count of prayers already prayed for today in greeting 2026-01-16 08:54:09 -05:00
nmemmert 8812d3d6ab feat: add personalized time-based greeting with user name and active prayer count 2026-01-16 08:52:50 -05:00
nmemmert 1340440c42 feat: rebrand app to Haver with Hebrew meaning in About section 2026-01-16 08:49:30 -05:00
nmemmert 6c33fc4606 feat: add 'Mark Prayed' button for prayer items with streak tracking 2026-01-16 08:47:10 -05:00
nmemmert 8105d39554 feat: add prayer frequency tracking with 1-7 slider and frequency compliance calculation 2026-01-16 08:40:09 -05:00
nmemmert 4225ef017c fix: update Prayer interfaces to include userId in all components 2026-01-16 08:31:49 -05:00
nmemmert a67f295089 feat: integrate Things to Pray For tab and add dashboard widget 2026-01-16 08:29:51 -05:00
nmemmert 55076bbc0e feat: enhance dashboard with real streak calculation, answered prayers section, and more stats 2026-01-16 08:25:02 -05:00
nmemmert 1d9e2a5d38 feat: make recent prayers clickable to edit from dashboard 2026-01-16 08:21:00 -05:00
nmemmert ddad533c81 feat: upgrade UI with advanced modern design - glassmorphism, gradients, animations 2026-01-16 08:18:54 -05:00
nmemmert b013e972d2 feat: enhance search with fuzzy matching, word-based filtering, and answered/unanswered filters 2026-01-16 08:16:27 -05:00
nmemmert bbc13237cc fix: add missing closing brace for about tab 2026-01-16 08:13:51 -05:00
nmemmert 15e81a8957 refactor: rename Settings tab to About, remove SettingsPage component 2026-01-16 08:13:24 -05:00
nmemmert 872fb889b6 fix: close head tag in layout.tsx 2026-01-16 08:05:15 -05:00
nmemmert 0d84231ec1 refactor: remove theme switching, keep dark mode permanent 2026-01-16 08:03:57 -05:00
nmemmert a6fcf0eb7b fix: add page reload back to apply theme changes 2026-01-16 08:01:45 -05:00
nmemmert c18a3268fc fix: remove CSS injection, rely on dark class and Tailwind 2026-01-16 07:59:16 -05:00
nmemmert 521bb593a9 fix: simplify CSS injection to be less destructive 2026-01-16 07:57:47 -05:00
nmemmert af03bf4dc8 fix: inject global CSS override styles for theme switching 2026-01-15 16:55:30 -05:00
nmemmert 03f2b1670f fix: use inline styles for theme switching instead of relying on dark class 2026-01-15 16:54:00 -05:00
nmemmert a5a03792f6 fix: remove page reload from theme changes, rely on dark class application 2026-01-15 16:36:06 -05:00
nmemmert 6b2d716cc4 fix: add page reload to apply theme changes 2026-01-15 16:33:56 -05:00
nmemmert 58480cd630 fix: add theme initialization script in head and toast feedback for theme changes 2026-01-15 16:33:21 -05:00
nmemmert bd7b9bea1a fix: simplify theme switching to use direct DOM manipulation and localStorage 2026-01-15 16:13:27 -05:00
nmemmert 6425c155ce fix: implement working theme selector with localStorage persistence 2026-01-15 15:00:13 -05:00
nmemmert 5afc5d754c fix: enable system theme support and adjust settings page layout 2026-01-15 14:58:50 -05:00
nmemmert 12377187c8 feat: add answered prayers tab with answer details tracking 2026-01-15 14:47:52 -05:00
nmemmert 159a7463cd feat: separate prayer compose and calendar into different tabs 2026-01-15 14:35:16 -05:00
nmemmert eb9f1a6a9b fix: increase auth loading timeout to 2 seconds for better Firebase compatibility 2026-01-15 14:32:54 -05:00
nmemmert db88fa52c0 fix: wrap content with larger top padding container to show clock 2026-01-15 14:22:34 -05:00
nmemmert b942681561 fix: increase top and bottom padding to prevent navbar overlap 2026-01-15 14:13:21 -05:00
nmemmert 782a7a5307 fix: add top padding to dashboard to prevent navbar overlap 2026-01-15 14:12:55 -05:00
nmemmert 6ab7ba35e3 feat: add live clock to dashboard 2026-01-15 14:12:32 -05:00
nmemmert 7237aa6181 feat: redesign home tab as dashboard with stats, move prayer entry to journal tab 2026-01-15 14:10:02 -05:00
nmemmert 8a5658a359 fix: add timeout to prevent auth loading from hanging indefinitely 2026-01-15 14:06:33 -05:00
nmemmert 9c73674c84 fix: restore page.tsx that was accidentally deleted
The main application page was deleted in the previous commit.
Recreated with all functionality for the daily prayer journal redesign.
2026-01-15 13:52:49 -05:00
nmemmert a5390e22be feat: add new simplified components for daily prayer journal
- DailyEntry.tsx: Main prayer input form with clean interface
- CalendarView.tsx: Monthly calendar showing entry indicators
- ReflectionsList.tsx: Browse and search past prayers
- SettingsPage.tsx: User preferences and account settings
- BottomNav.tsx: Mobile-first navigation with desktop support
- All components use Slate color scheme for intimate aesthetic
2026-01-15 13:44:07 -05:00
nmemmert b032d14673 feat: redesign app for daily prayer journal focus
- Simplified Prayer interface (removed chains, goals, templates)
- Created DailyEntry component for today's prayer entry
- Created CalendarView component with monthly calendar
- Created ReflectionsList component for browsing past prayers
- Created SettingsPage component for user preferences
- Created BottomNav for mobile-first navigation
- Replaced complex main page with simplified version
- Focused on core functionality: write, journal, reflect, archive
- Clean, intimate aesthetic matching 'My Prayer Journal - Daily' app
2026-01-15 13:40:49 -05:00
nmemmert 7293dbdc34 Fix Docker deployment issues and add CasaOS configuration
- Add missing Firebase Admin environment variables to docker-compose.yml
- Update CasaOS configuration with gracious_eldan naming
- Fix Firebase admin initialization for build-time compatibility
- Add missing component imports in main page
- Fix TypeScript compilation errors in API routes
- Update port mapping to 3050 for CasaOS deployment
2025-12-18 08:06:05 -05:00
nmemmert 27d02596d1 docs: Add Docker deployment instructions and build script
- Add build-and-push-docker.ps1 PowerShell script for automated Docker builds
- Update README.md with comprehensive Docker deployment guide
- Include environment variable configuration
- Document automated email scheduling in containers
- Add GitHub Container Registry push instructions
2025-12-17 13:51:52 -05:00
nmemmert d9784738e4 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
2025-12-17 13:50:21 -05:00
nmemmert 7214fdb89a Update firebase.json to remove problematic lint predeploy hook 2025-11-03 08:52:43 -05:00
nmemmert a2ff235dcc Add Firebase Functions for automated prayer reminders 2025-11-03 08:30:53 -05:00
nmemmert 5ea16bba66 Add FCM VAPID key setup and update .gitignore 2025-11-03 08:08:38 -05:00
nmemmert 1a160135d7 Add enhanced search/filtering and push notifications for prayer reminders 2025-11-03 08:04:19 -05:00
nmemmert aa3f42479f Add edit functionality for prayers and praises 2025-10-23 16:51:11 -04:00
nmemmert fdfc4f5f3d Add PWA icons to metadata and manifest for better iOS/Android support 2025-10-23 11:49:47 -04:00
nmemmert 9bb50b115d Fix theming, profile image, and add app icons 2025-10-23 11:40:39 -04:00
nmemmert 5ba808966e Add admin functionality to clear all community prayers from sharedPrayers collection 2025-10-23 09:02:57 -04:00
nmemmert b7a1eab925 Add Firestore rules for sharedPrayers collection to enable community sharing functionality 2025-10-23 08:44:28 -04:00
nmemmert 39d621a0be Fix build error: create custom next-pwa declaration file to resolve TypeScript conflicts with Next.js 16 2025-10-23 08:26:53 -04:00
nmemmert 6a997d9931 Fix remaining ESLint errors: replace img with Next.js Image, fix useEffect dependency, correct nodemailer method 2025-10-23 08:19:18 -04:00
nmemmert 8b189f4caa Fix ESLint errors: replace any types, escape HTML entities, convert require to imports, ignore config files 2025-10-23 08:13:39 -04:00
nmemmert 3d8ba5b8f0 Implement comprehensive enhancements: push notifications, export functionality, user profiles, community sharing, offline improvements, analytics, accessibility, testing, and documentation 2025-10-23 08:08:20 -04:00
nmemmert 1932dbe1fc Update docker-compose.yml for production deployment
- Use GitHub Container Registry image instead of local build
- Add EmailJS environment variables for email functionality
- Configure proper networking and health checks
2025-10-22 15:15:54 -04:00
nmemmert 9f31fd3e79 Update PWA service worker and workbox files
- Update service worker for latest build
- Update workbox cache files
- EmailJS integration ready for deployment
2025-10-22 15:13:46 -04:00
nmemmert 45207b5851 Add EmailJS integration for free email sending
- Install @emailjs/browser for client-side email
- Update prayer submission to send immediate emails via EmailJS
- Add EmailJS configuration to environment variables
- Update README with EmailJS setup instructions
- Keep server-side scheduled emails for cron jobs
2025-10-22 15:00:24 -04:00
nmemmert bc4dec65b3 Fix cron daemon startup in Docker container
- Install su-exec for user switching
- Modify startup script to run cron as root and app as nextjs user
- Remove USER directive to allow proper service management
- Cron daemon now starts successfully alongside Next.js app
2025-10-22 14:38:03 -04:00
nmemmert 5131740727 Fix Docker cron permissions
- Move cron file and startup script creation before USER directive
- Set proper ownership for cron files and startup script
- Fix permission denied errors in Docker build
2025-10-22 14:33:22 -04:00
nmemmert 9f799397c8 Implement cross-device prayer synchronization
- Replace localStorage with Firestore for prayer persistence
- Prayers now sync across all devices when logged in
- Add userId to prayers for proper user association
- Update archive/unarchive to work with Firestore
- Update Firestore rules for user-specific prayer access
- Remove localStorage dependencies
2025-10-22 14:27:39 -04:00
nmemmert 6b0713612d Implement scheduled email reminders
- Add cron service to Docker container for automated email sending
- Create /api/send-scheduled-emails endpoint for cron job calls
- Modify prayer submission to store user reminder preferences instead of sending immediate emails
- Update Firestore rules to allow server-side API access for scheduled emails
- Add userId to prayers for proper user association
- Cron runs every 5 minutes to check and send scheduled reminders
2025-10-22 14:20:21 -04:00
nmemmert 220788ef70 Complete Firebase setup with Data Connect and security rules
- Initialize Firebase Data Connect
- Deploy Firestore security rules for SMTP config
- Update firebase.json with Firestore configuration
- Add generated Data Connect SDKs
2025-10-22 14:10:28 -04:00
nmemmert 0f1e8774d7 Add Firestore security rules and deployment scripts
- Add firestore.rules for SMTP config permissions
- Add deployment scripts for Firestore rules
- Update Admin component with better error handling
- Update README with deployment instructions
2025-10-22 13:33:42 -04:00
nmemmert bd6e1eea7c Fix Node.js version requirement
- Update Dockerfile to use Node.js 20 (required for Next.js 16)
- Next.js 16 requires Node.js >=20.9.0, was using 18.20.8
2025-10-22 13:18:26 -04:00
nmemmert 08ca9ed7da Fix Docker build issues
- Fix ENV format warnings in Dockerfile
- Set NODE_ENV=production during build
- Enable PWA plugin for all environments to ensure consistent builds
- Add debugging for build failures
2025-10-22 13:10:27 -04:00
nmemmert aa2c28d444 Simplify Dockerfile build process
- Remove problematic multi-stage deps optimization
- Install all dependencies directly in builder stage
- Ensure reliable builds by avoiding dependency conflicts
2025-10-22 13:05:49 -04:00
nmemmert c054fbc404 Fix Dockerfile build dependencies
- Install all dependencies (including dev dependencies) in builder stage
- Ensure TypeScript, Tailwind, and other build tools are available during build
2025-10-22 13:02:44 -04:00
nmemmert 16a2a19d26 Fix GitHub Actions Docker workflow
- Remove problematic cache configuration that was causing build failures
- Simplify workflow for reliable Docker image building
2025-10-22 12:59:47 -04:00
nmemmert 29a50dcc9b Update README with Docker instructions 2025-10-22 12:44:23 -04:00
nmemmert af8b346ff6 Add GitHub Actions workflow for Docker image building
- Add CI/CD pipeline to build and push Docker images
- Configure GitHub Container Registry integration
- Enable caching for faster builds
2025-10-22 12:43:44 -04:00
nmemmert fba1097380 Add Docker support and containerization
- Add Dockerfile for Next.js application
- Configure standalone output for Docker
- Add .dockerignore file
- Add docker-compose.yml for easy deployment
- Update next.config.ts for Docker compatibility
2025-10-22 12:42:41 -04:00
nmemmert 91d417159d Initial commit from Create Next App 2025-10-22 10:49:14 -04:00
102 changed files with 28538 additions and 9696 deletions

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
# Code Citations
## License: unknown
https://github.com/code-art4/chat-app/blob/6a6e13a2e9e11d5e9fa599fd732a910dd126e3dc/src/firebase.js
```
// Import the functions you need from the SDKs you need
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
// Your web app's Firebase configuration
const firebaseConfig =
```
## License: unknown
https://github.com/trinac-devs/crwn-clothing/blob/e54025b3d0f5ee043a783e854924937db81b6b38/firebase.util.js
```
// Import the functions you need from the SDKs you need
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
// Your web app's Firebase configuration
const firebaseConfig =
```
## License: unknown
https://github.com/ricjohn-acosta/guisee/blob/893cab7da639ab586c5ccfe7cb467d0bce134465/server/firebase.js
```
// Import the functions you need from the SDKs you need
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
// Your web app's Firebase configuration
const firebaseConfig =
```
## License: unknown
https://github.com/katayama8000/try-something-new-for-30days-web/blob/f0bb3b9f02262929cf06a28199dfc7c28dd574a6/firebase/firebase.ts
```
// Import the functions you need from the SDKs you need
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
// Your web app's Firebase configuration
const firebaseConfig =
```
## License: unknown
https://github.com/Siva-01/house-market/blob/0b2d7c7a6b0d6b90946ca77e0638e239cdca2e1a/src/firebase.config.js
```
// Import the functions you need from the SDKs you need
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
// Your web app's Firebase configuration
const firebaseConfig =
```
+43
View File
@@ -0,0 +1,43 @@
Dockerfile
.dockerignore
.git
.gitignore
README.md
.env*.local
.next
.vercel
*.md
functions
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage
# Next.js
.next/
out/
# Production
build
# Misc
.DS_Store
*.pem
# Debug
*.log
# Local env files
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts
+4
View File
@@ -0,0 +1,4 @@
# EmailJS Configuration (Free Email Service)
NEXT_PUBLIC_EMAILJS_SERVICE_ID=your_emailjs_service_id
NEXT_PUBLIC_EMAILJS_TEMPLATE_ID=your_emailjs_template_id
NEXT_PUBLIC_EMAILJS_PUBLIC_KEY=your_emailjs_public_key
+5
View File
@@ -0,0 +1,5 @@
{
"projects": {
"default": "prayer-app-6701f"
}
}
+51
View File
@@ -0,0 +1,51 @@
name: Build & Publish Docker Image
on:
push:
branches:
- main
pull_request:
branches:
- main
env:
REGISTRY: git.necloud.us
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+23
View File
@@ -0,0 +1,23 @@
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [x] Clarify Project Requirements
- [x] Scaffold the Project
Completed: Scaffolded Next.js project with TypeScript, Tailwind CSS, ESLint, and App Router.
- [x] Customize the Project
- [x] Install Required Extensions
- [x] Compile the Project
Completed: Built successfully with webpack, PWA configured.
- [x] Create and Run Task
- [x] Launch the Project
- [x] Ensure Documentation is Complete
- Work through each checklist item systematically.
- Keep communication concise and focused.
- Follow development best practices.
+26
View File
@@ -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
+28 -34
View File
@@ -1,12 +1,10 @@
name: Build & Publish Docker Image
name: Build and Push Docker Image
on:
push:
branches:
- main
branches: [ main, master ]
pull_request:
branches:
- main
branches: [ main, master ]
env:
REGISTRY: ghcr.io
@@ -20,35 +18,31 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+48
View File
@@ -0,0 +1,48 @@
name: Version Bump
on:
push:
branches:
- '**'
jobs:
bump:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install deps
run: npm ci
- name: Determine bump type
id: bump
run: |
MSG=$(git log -1 --pretty=%B)
echo "Commit message: $MSG"
if echo "$MSG" | grep -qi "bump:major"; then
echo "type=major" >> $GITHUB_OUTPUT
elif echo "$MSG" | grep -qi "bump:minor"; then
echo "type=minor" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
- name: Bump version
run: |
npm version ${{ steps.bump.outputs.type }} -m "chore: version bump to %s"
node scripts/sync-version.js
- name: Commit version files
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add package.json package-lock.json src/lib/version.ts
git commit -m "chore: sync version metadata" || echo "No changes to commit"
- name: Push changes
run: |
git push --follow-tags
+25 -5
View File
@@ -3,21 +3,41 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# Firebase service account keys
firebase-service-account.json
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+7
View File
@@ -0,0 +1,7 @@
{
"chat.tools.terminal.autoApprove": {
"npm install": true,
"git add": true,
"git commit": true
}
}
+70 -12
View File
@@ -1,18 +1,76 @@
FROM node:20-alpine AS build
# Use the official Node.js 20 Alpine image as the base
FROM node:20-alpine AS base
# Install dependencies and build in the same stage for simplicity
FROM base AS builder
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
# Copy package files
COPY package.json package-lock.json* ./
# Install all dependencies (including dev dependencies)
RUN npm ci
# Copy source code
COPY . .
RUN npm run build
FROM node:20-alpine
# Set environment for build
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build || (echo "Build failed, showing logs:" && cat /tmp/build.log 2>/dev/null || echo "No build log found" && exit 1)
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY --from=build /app/build ./build
ENV DATA_DIR=/data
VOLUME ["/data"]
EXPOSE 4000
CMD ["node", "server.js"]
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Install cron and curl for scheduled tasks
RUN apk add --no-cache dcron curl su-exec
# Create cron log directory
RUN mkdir -p /var/log/cron && chown nextjs:nodejs /var/log/cron
# Create cron job file for nextjs user
RUN echo "*/5 * * * * curl -s http://localhost:3000/api/send-scheduled-emails >> /var/log/cron/scheduled-emails.log 2>&1" > /etc/crontabs/nextjs && \
chown nextjs:nodejs /etc/crontabs/nextjs && \
chmod 600 /etc/crontabs/nextjs
# Create startup script that runs cron as root and app as nextjs
RUN echo '#!/bin/sh' > /app/start.sh && \
echo '# Start cron daemon as root' >> /app/start.sh && \
echo 'crond -f -l 8 &' >> /app/start.sh && \
echo '# Wait a moment for cron to start' >> /app/start.sh && \
echo 'sleep 1' >> /app/start.sh && \
echo '# Switch to nextjs user and start the app' >> /app/start.sh && \
echo 'exec su-exec nextjs:nodejs "$@"' >> /app/start.sh && \
chmod +x /app/start.sh
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
EXPOSE 3000
ENV PORT=3000
# set hostname to localhost
ENV HOSTNAME="0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["/app/start.sh", "node", "server.js"]
+52 -58
View File
@@ -1,85 +1,79 @@
# Prayer Journal
A todo-list style prayer app. Add prayer requests, record how God answered them, filter between active and answered, and tag entries for organization. Data persists to a mounted Docker volume.
A minimalist web app for journaling daily prayers, tracking answered prayers, and receiving push reminders. Self-hostable via Docker.
## Running with Docker
**Current version: 0.4.0**
### Pull the pre-built image (recommended)
## Features
- Daily prayer composition with optional "Pray For" person
- Mark prayers as answered with notes
- Calendar view of past entries
- Reflections list with search and filters
- Answered prayers gallery
- Push notifications via [ntfy](https://ntfy.sh) (no account required) — daily digest and per-prayer reminders
- Export prayer log as PDF or DOCX
- PWA installable, dark theme by default
## Stack
- **Frontend**: Next.js + React, Tailwind CSS, Firebase (auth + Firestore)
- **Backend**: Express server (`server.js`) — local JSON storage, cron-based ntfy notifications, DOCX/PDF export
- **Container**: Docker, published to `git.necloud.us`
## Docker (recommended)
```bash
docker compose up -d
```
The `docker-compose.yml` pulls `ghcr.io/nmemmert/prayer:latest` automatically. Open [http://localhost:3000](http://localhost:3000).
The app listens on port `3050` by default. Adjust environment variables in `docker-compose.yml` as needed.
### Build locally
### Environment variables
```bash
docker compose up --build -d
```
| Variable | Description |
|---|---|
| `FIREBASE_PROJECT_ID` | Firebase project ID |
| `FIREBASE_CLIENT_EMAIL` | Firebase Admin SDK service account email |
| `FIREBASE_PRIVATE_KEY` | Firebase Admin SDK private key |
| `NODE_ENV` | Set to `production` for Docker deploys |
### Data persistence
> Store secrets in a `.env` file and reference them in `docker-compose.yml` — do not hardcode private keys in compose files.
Prayer data is stored in `/data/prayers.json` inside the container, mounted as the named Docker volume `prayer-data`. Data survives container restarts and image updates.
## Push notifications (ntfy)
To inspect or back up your data:
ntfy requires no account. Set your topic in the app's Settings, then notifications arrive via the [ntfy app](https://ntfy.sh) or any ntfy-compatible client.
```bash
# Print current prayers
docker compose exec prayer cat /data/prayers.json
# Copy to host
docker cp $(docker compose ps -q prayer):/data/prayers.json ./prayers-backup.json
```
To wipe all data:
```bash
docker compose down -v
```
### Using a host-bind mount instead of a named volume
Replace the `volumes` block in `docker-compose.yml`:
```yaml
volumes:
- ./prayer-data:/data
```
## CI / CD
Pushes to `main` trigger a GitHub Actions workflow (`.github/workflows/docker.yml`) that:
1. Builds the Docker image (multi-stage: Node 20 → Node 20 slim)
2. Pushes to GitHub Container Registry (`ghcr.io/nmemmert/prayer`)
3. Tags the image as `latest`, the branch name, and the short commit SHA
Pull requests run the build step only (no push).
The `GITHUB_TOKEN` secret is used automatically — no additional secrets are required.
- **Daily digest** — morning summary of unprayed prayers
- **Per-prayer reminders** — cron-scheduled nudges for individual prayers
- **Test notification** — trigger one from the Settings page to verify
## Development
```bash
npm install
npm start # React dev server on :3000 (uses localStorage, no backend)
node server.js # API server on :4000 (reads/writes data/prayers.json)
npm run dev
```
For full local dev with the backend:
App runs at `http://localhost:3000`.
```bash
node server.js &
REACT_APP_API_BASE=http://localhost:4000 npm start
npm run build # production build
npm run lint # lint
npm test # run tests
```
## Stack
## CI / Deployment
| Layer | Technology |
|----------|-----------------------------------|
| Frontend | React 18, TypeScript |
| Backend | Node.js, Express |
| Data | JSON file on a Docker volume |
| Images | GitHub Container Registry (ghcr) |
| CI/CD | GitHub Actions |
Pushes to `master` trigger a Gitea Actions workflow (`.gitea/workflows/docker.yml`) that builds and pushes the Docker image to `git.necloud.us`.
Secrets required in Gitea repo settings:
| Secret | Value |
|---|---|
| `REGISTRY_USER` | Gitea username |
| `REGISTRY_TOKEN` | Gitea access token with package write scope |
## Versioning
Semantic versioning — `npm run version:minor` or `npm run version:major` bumps the version and syncs it across the project.
+32
View File
@@ -0,0 +1,32 @@
# Docker Build and Push Script for Prayer App
# Run this script to build and push the Docker container to GitHub Container Registry
Write-Host "🔨 Building Docker image..." -ForegroundColor Green
docker build -t ghcr.io/nmemmert/prayer-app:latest .
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ Docker image built successfully!" -ForegroundColor Green
Write-Host "🔐 Logging into GitHub Container Registry..." -ForegroundColor Yellow
Write-Host "Please enter your GitHub Personal Access Token (with package write permissions):" -ForegroundColor Yellow
$token = Read-Host -AsSecureString
$tokenPlain = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($token))
echo $tokenPlain | docker login ghcr.io -u nmemmert --password-stdin
if ($LASTEXITCODE -eq 0) {
Write-Host "🚀 Pushing Docker image to GitHub Container Registry..." -ForegroundColor Green
docker push ghcr.io/nmemmert/prayer-app:latest
if ($LASTEXITCODE -eq 0) {
Write-Host "✅ Docker image pushed successfully!" -ForegroundColor Green
Write-Host "🌐 Your container is now available at: ghcr.io/nmemmert/prayer-app:latest" -ForegroundColor Cyan
} else {
Write-Host "❌ Failed to push Docker image" -ForegroundColor Red
}
} else {
Write-Host "❌ Failed to login to GitHub Container Registry" -ForegroundColor Red
}
} else {
Write-Host "❌ Failed to build Docker image" -ForegroundColor Red
}
+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();
+13
View File
@@ -0,0 +1,13 @@
specVersion: "v1"
serviceId: "prayer"
location: "us-east4"
schema:
source: "./schema"
datasource:
postgresql:
database: "fdcdb"
cloudSql:
instanceId: "prayer-fdc"
# schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly.
# schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect.
connectorDirs: ["./example"]
+8
View File
@@ -0,0 +1,8 @@
connectorId: example
generate:
javascriptSdk:
- outputDir: ..\..\src\dataconnect-generated
package: "@dataconnect/generated"
packageJsonDir: ..\..
react: true
angular: false
+33
View File
@@ -0,0 +1,33 @@
# Example mutations for a simple movie app
# Create a movie based on user input
mutation CreateMovie($title: String!, $genre: String!, $imageUrl: String!)
@auth(level: USER_EMAIL_VERIFIED, insecureReason: "Any email verified users can create a new movie.") {
movie_insert(data: { title: $title, genre: $genre, imageUrl: $imageUrl })
}
# Upsert (update or insert) a user's username based on their auth.uid
mutation UpsertUser($username: String!) @auth(level: USER) {
# The "auth.uid" server value ensures that users can only register their own user.
user_upsert(data: { id_expr: "auth.uid", username: $username })
}
# Add a review for a movie
mutation AddReview($movieId: UUID!, $rating: Int!, $reviewText: String!)
@auth(level: USER) {
review_upsert(
data: {
userId_expr: "auth.uid"
movieId: $movieId
rating: $rating
reviewText: $reviewText
# reviewDate defaults to today in the schema. No need to set it manually.
}
)
}
# Logged in user can delete their review for a movie
mutation DeleteReview($movieId: UUID!) @auth(level: USER) {
# The "auth.uid" server value ensures that users can only delete their own reviews.
review_delete(key: { userId_expr: "auth.uid", movieId: $movieId })
}
+78
View File
@@ -0,0 +1,78 @@
# Example queries for a simple movie app.
# @auth() directives control who can call each operation.
# Anyone should be able to list all movies, so the auth level is set to PUBLIC
query ListMovies @auth(level: PUBLIC, insecureReason: "Anyone can list all movies.") {
movies {
id
title
imageUrl
genre
}
}
# List all users, only admins should be able to list all users, so we use NO_ACCESS
query ListUsers @auth(level: NO_ACCESS) {
users {
id
username
}
}
# Logged in users can list all their reviews and movie titles associated with the review
# Since the query uses the uid of the current authenticated user, we set auth level to USER
query ListUserReviews @auth(level: USER) {
user(key: { id_expr: "auth.uid" }) {
id
username
# <field>_on_<foreign_key_field> makes it easy to grab info from another table
# Here, we use it to grab all the reviews written by the user.
reviews: reviews_on_user {
rating
reviewDate
reviewText
movie {
id
title
}
}
}
}
# Get movie by id
query GetMovieById($id: UUID!) @auth(level: PUBLIC, insecureReason: "Anyone can get a movie by id.") {
movie(id: $id) {
id
title
imageUrl
genre
metadata: movieMetadata_on_movie {
rating
releaseYear
description
}
reviews: reviews_on_movie {
reviewText
reviewDate
rating
user {
id
username
}
}
}
}
# Search for movies, actors, and reviews
query SearchMovie($titleInput: String, $genre: String) @auth(level: PUBLIC, insecureReason: "Anyone can search for movies.") {
movies(
where: {
_and: [{ genre: { eq: $genre } }, { title: { contains: $titleInput } }]
}
) {
id
title
genre
imageUrl
}
}
+52
View File
@@ -0,0 +1,52 @@
# Example schema for simple movie review app
# User table is keyed by Firebase Auth UID.
type User @table {
# `@default(expr: "auth.uid")` sets it to Firebase Auth UID during insert and upsert.
id: String! @default(expr: "auth.uid")
username: String! @col(dataType: "varchar(50)")
# The `user: User!` field in the Review table generates the following one-to-many query field.
# reviews_on_user: [Review!]!
# The `Review` join table the following many-to-many query field.
# movies_via_Review: [Movie!]!
}
# Movie is keyed by a randomly generated UUID.
type Movie @table {
# If you do not pass a 'key' to `@table`, Data Connect automatically adds the following 'id' column.
# Feel free to uncomment and customize it.
# id: UUID! @default(expr: "uuidV4()")
title: String!
imageUrl: String!
genre: String
}
# MovieMetadata is a metadata attached to a Movie.
# Movie <-> MovieMetadata is a one-to-one relationship
type MovieMetadata @table {
# @unique ensures each Movie can only one MovieMetadata.
movie: Movie! @unique
# The movie field adds the following foreign key field. Feel free to uncomment and customize it.
# movieId: UUID!
rating: Float
releaseYear: Int
description: String
}
# Reviews is a join table between User and Movie.
# It has a composite primary keys `userUid` and `movieId`.
# A user can leave reviews for many movies. A movie can have reviews from many users.
# User <-> Review is a one-to-many relationship
# Movie <-> Review is a one-to-many relationship
# Movie <-> User is a many-to-many relationship
type Review @table(name: "Reviews", key: ["movie", "user"]) {
user: User!
# The user field adds the following foreign key field. Feel free to uncomment and customize it.
# userUid: String!
movie: Movie!
# The movie field adds the following foreign key field. Feel free to uncomment and customize it.
# movieId: UUID!
rating: Int
reviewText: String
reviewDate: Date! @default(expr: "request.time")
}
+279
View File
@@ -0,0 +1,279 @@
mutation @transaction {
movie_insertMany(
data: [
{
id: "550e8400-e29b-41d4-a716-446655440000",
title: "Quantum Paradox",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fquantum_paradox.jpeg?alt=media&token=4142e2a1-bf43-43b5-b7cf-6616be3fd4e3",
genre: "sci-fi"
},
{
id: "550e8400-e29b-41d4-a716-446655440001",
title: "The Lone Outlaw",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Flone_outlaw.jpeg?alt=media&token=15525ffc-208f-4b59-b506-ae8348e06e85",
genre: "western"
},
{
id: "550e8400-e29b-41d4-a716-446655440002",
title: "Celestial Harmony",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fcelestial_harmony.jpeg?alt=media&token=3edf1cf9-c2f5-4c75-9819-36ff6a734c9a",
genre: "romance"
},
{
id: "550e8400-e29b-41d4-a716-446655440003",
title: "Noir Mystique",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fnoir_mystique.jpeg?alt=media&token=3299adba-cb98-4302-8b23-aeb679a4f913",
genre: "mystery"
},
{
id: "550e8400-e29b-41d4-a716-446655440004",
title: "The Forgotten Island",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fforgotten_island.jpeg?alt=media&token=bc2b16e1-caed-4649-952c-73b6113f205c",
genre: "adventure"
},
{
id: "550e8400-e29b-41d4-a716-446655440005",
title: "Digital Nightmare",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fdigital_nightmare.jpeg?alt=media&token=335ec842-1ca4-4b09-abd1-e96d9f5c0c2f",
genre: "horror"
},
{
id: "550e8400-e29b-41d4-a716-446655440006",
title: "Eclipse of Destiny",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Feclipse_destiny.jpeg?alt=media&token=346649b3-cb5c-4d7e-b0d4-6f02e3df5959",
genre: "fantasy"
},
{
id: "550e8400-e29b-41d4-a716-446655440007",
title: "Heart of Steel",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fheart_steel.jpeg?alt=media&token=17883d71-329b-415a-86f8-dd4d9e941d7f",
genre: "sci-fi"
},
{
id: "550e8400-e29b-41d4-a716-446655440008",
title: "Rise of the Crimson Empire",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Frise_crimson_empire.jpeg?alt=media&token=6faa73ad-7504-4146-8f3a-50b90f607f33",
genre: "action"
},
{
id: "550e8400-e29b-41d4-a716-446655440009",
title: "Silent Waves",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fsilent_waves.jpeg?alt=media&token=bd626bf1-ec60-4e57-aa07-87ba14e35bb7",
genre: "drama"
},
{
id: "550e8400-e29b-41d4-a716-446655440010",
title: "Echoes of the Past",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fecho_of_past.jpeg?alt=media&token=d866aa27-8534-4d72-8988-9da4a1b9e452",
genre: "historical"
},
{
id: "550e8400-e29b-41d4-a716-446655440011",
title: "Beyond the Horizon",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fbeyond_horizon.jpeg?alt=media&token=31493973-0692-4e6e-8b88-afb1aaea17ee",
genre: "sci-fi"
},
{
id: "550e8400-e29b-41d4-a716-446655440012",
title: "Shadows and Lies",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fshadows_lies.jpeg?alt=media&token=01afb80d-caee-47f8-a00e-aea8b9e459a2",
genre: "crime"
},
{
id: "550e8400-e29b-41d4-a716-446655440013",
title: "The Last Symphony",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Flast_symphony.jpeg?alt=media&token=f9bf80cd-3d8e-4e24-8503-7feb11f4e397",
genre: "drama"
},
{
id: "550e8400-e29b-41d4-a716-446655440014",
title: "Moonlit Crusade",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fmoonlit_crusade.jpeg?alt=media&token=b13241f5-d7d0-4370-b651-07847ad99dc2",
genre: "fantasy"
},
{
id: "550e8400-e29b-41d4-a716-446655440015",
title: "Abyss of the Deep",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fabyss_deep.jpeg?alt=media&token=2417321d-2451-4ec0-9ed6-6297042170e6",
genre: "horror"
},
{
id: "550e8400-e29b-41d4-a716-446655440017",
title: "The Infinite Knot",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Finfinite_knot.jpeg?alt=media&token=93d54d93-d933-4663-a6fe-26b707ef823e",
genre: "romance"
},
{
id: "550e8400-e29b-41d4-a716-446655440019",
title: "Veil of Illusion",
imageUrl: "https://firebasestorage.googleapis.com/v0/b/fdc-web-quickstart.appspot.com/o/movie%2Fveil_illusion.jpeg?alt=media&token=7bf09a3c-c531-478a-9d02-5d99fca9393b",
genre: "mystery"
}
]
)
movieMetadata_insertMany(
data: [
{
movieId: "550e8400-e29b-41d4-a716-446655440000",
rating: 7.9,
releaseYear: 2025,
description: "A group of scientists accidentally open a portal to a parallel universe, causing a rift in time. As the team races to close the portal, they encounter alternate versions of themselves, leading to shocking revelations."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440001",
rating: 8.2,
releaseYear: 2023,
description: "In the lawless Wild West, a mysterious gunslinger with a hidden past takes on a corrupt sheriff and his band of outlaws to bring justice to a small town."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440002",
rating: 7.5,
releaseYear: 2024,
description: "Two astronauts, stationed on a remote space station, fall in love amidst the isolation of deep space. But when a mysterious signal disrupts their communication, they must find a way to reconnect and survive."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440003",
rating: 8.0,
releaseYear: 2022,
description: "A private detective gets caught up in a web of lies, deception, and betrayal while investigating the disappearance of a famous actress in 1940s Hollywood."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440004",
rating: 7.6,
releaseYear: 2025,
description: "An explorer leads an expedition to a remote island rumored to be home to mythical creatures. As the team ventures deeper into the island, they uncover secrets that change the course of history."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440005",
rating: 6.9,
releaseYear: 2024,
description: "A tech-savvy teenager discovers a cursed app that brings nightmares to life. As the horrors of the digital world cross into reality, she must find a way to break the curse before it's too late."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440006",
rating: 8.1,
releaseYear: 2026,
description: "In a kingdom on the brink of war, a prophecy speaks of an eclipse that will grant power to the rightful ruler. As factions vie for control, a young warrior must decide where his true loyalty lies."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440007",
rating: 7.7,
releaseYear: 2023,
description: "A brilliant scientist creates a robot with a human heart. As the robot struggles to understand emotions, it becomes entangled in a plot that could change the fate of humanity."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440008",
rating: 8.4,
releaseYear: 2025,
description: "A legendary warrior rises to challenge the tyrannical rule of a powerful empire. As rebellion brews, the warrior must unite different factions to lead an uprising."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440009",
rating: 8.2,
releaseYear: 2024,
description: "A talented pianist, who loses his hearing in a tragic accident, must rediscover his passion for music with the help of a young music teacher who believes in him."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440010",
rating: 7.8,
releaseYear: 2023,
description: "A historian stumbles upon an ancient artifact that reveals hidden truths about an empire long forgotten. As she deciphers the clues, a shadowy organization tries to stop her from unearthing the past."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440011",
rating: 8.5,
releaseYear: 2026,
description: "In the future, Earth's best pilots are sent on a mission to explore a mysterious planet beyond the solar system. What they find changes humanity's understanding of the universe forever."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440012",
rating: 7.9,
releaseYear: 2022,
description: "A young detective with a dark past investigates a series of mysterious murders in a city plagued by corruption. As she digs deeper, she realizes nothing is as it seems."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440013",
rating: 8.0,
releaseYear: 2024,
description: "An aging composer struggling with memory loss attempts to complete his final symphony. With the help of a young prodigy, he embarks on an emotional journey through his memories and legacy."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440014",
rating: 8.3,
releaseYear: 2025,
description: "A knight is chosen by an ancient order to embark on a quest under the light of the full moon. Facing mythical beasts and treacherous landscapes, he seeks a relic that could save his kingdom."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440015",
rating: 7.2,
releaseYear: 2023,
description: "When a group of marine biologists descends into the unexplored depths of the ocean, they encounter a terrifying and ancient force. Now, they must survive as the abyss comes alive."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440017",
rating: 7.4,
releaseYear: 2026,
description: "Two souls destined to meet across multiple lifetimes struggle to find each other in a chaotic world. With each incarnation, they get closer, but time itself becomes their greatest obstacle."
},
{
movieId: "550e8400-e29b-41d4-a716-446655440019",
rating: 7.8,
releaseYear: 2022,
description: "A magician-turned-detective uses his skills in illusion to solve crimes. When a series of murders leaves the city in fear, he must reveal the truth hidden behind a veil of deceit."
}
]
)
user_insertMany(
data: [
{ id: "SnLgOC3lN4hcIl69s53cW0Q8R1T2", username: "sherlock_h" },
{ id: "fep4fXpGWsaRpuphq9CIrBIXQ0S2", username: "hercule_p" },
{ id: "TBedjwCX0Jf955Uuoxk6k74sY0l1", username: "jane_d" }
]
)
review_insertMany(
data: [
{
userId: "SnLgOC3lN4hcIl69s53cW0Q8R1T2",
movieId: "550e8400-e29b-41d4-a716-446655440000",
rating: 5,
reviewText: "An incredible movie with a mind-blowing plot!",
reviewDate: "2025-10-01"
},
{
userId: "fep4fXpGWsaRpuphq9CIrBIXQ0S2",
movieId: "550e8400-e29b-41d4-a716-446655440001",
rating: 5,
reviewText: "A revolutionary film that changed cinema forever.",
reviewDate: "2025-10-01"
},
{
userId: "TBedjwCX0Jf955Uuoxk6k74sY0l1",
movieId: "550e8400-e29b-41d4-a716-446655440002",
rating: 5,
reviewText: "A visually stunning and emotionally impactful movie.",
reviewDate: "2025-10-01"
},
{
userId: "SnLgOC3lN4hcIl69s53cW0Q8R1T2",
movieId: "550e8400-e29b-41d4-a716-446655440003",
rating: 4,
reviewText: "A fantastic superhero film with great performances.",
reviewDate: "2025-10-01"
},
{
userId: "fep4fXpGWsaRpuphq9CIrBIXQ0S2",
movieId: "550e8400-e29b-41d4-a716-446655440004",
rating: 5,
reviewText: "An amazing film that keeps you on the edge of your seat.",
reviewDate: "2025-10-01"
},
{
userId: "TBedjwCX0Jf955Uuoxk6k74sY0l1",
movieId: "550e8400-e29b-41d4-a716-446655440005",
rating: 5,
reviewText: "An absolute classic with unforgettable dialogue.",
reviewDate: "2025-10-01"
}
]
)
}
+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();
+46
View File
@@ -0,0 +1,46 @@
@echo off
echo 🚀 Deploying Firestore Security Rules...
echo.
REM Check if Firebase CLI is installed
firebase --version >nul 2>&1
if %errorlevel% neq 0 (
echo ❌ Firebase CLI is not installed. Please install it first:
echo npm install -g firebase-tools
pause
exit /b 1
)
REM Check if user is logged in
firebase projects:list >nul 2>&1
if %errorlevel% neq 0 (
echo 🔐 Please login to Firebase first:
echo firebase login
pause
exit /b 1
)
echo 📋 Available Firebase projects:
firebase projects:list
echo.
echo 🔗 Make sure you're using the correct project (prayer-app-6701f)
echo If not, run: firebase use prayer-app-6701f
echo.
pause
REM Deploy the rules
echo 📤 Deploying Firestore security rules...
firebase deploy --only firestore:rules
if %errorlevel% equ 0 (
echo.
echo ✅ Firestore security rules deployed successfully!
echo You can now save SMTP configuration in the admin panel.
) else (
echo.
echo ❌ Failed to deploy Firestore rules. Please check the error messages above.
pause
exit /b 1
)
pause
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
echo "🚀 Deploying Firestore Security Rules..."
echo ""
# Check if Firebase CLI is installed
if ! command -v firebase &> /dev/null; then
echo "❌ Firebase CLI is not installed. Please install it first:"
echo "npm install -g firebase-tools"
exit 1
fi
# Check if user is logged in
if ! firebase projects:list &> /dev/null; then
echo "🔐 Please login to Firebase first:"
echo "firebase login"
exit 1
fi
echo "📋 Available Firebase projects:"
firebase projects:list
echo ""
echo "🔗 Make sure you're using the correct project (prayer-app-6701f)"
echo "If not, run: firebase use prayer-app-6701f"
echo ""
read -p "Press Enter to deploy Firestore rules..."
# Deploy the rules
echo "📤 Deploying Firestore security rules..."
firebase deploy --only firestore:rules
if [ $? -eq 0 ]; then
echo ""
echo "✅ Firestore security rules deployed successfully!"
echo "You can now save SMTP configuration in the admin panel."
else
echo ""
echo "❌ Failed to deploy Firestore rules. Please check the error messages above."
exit 1
fi
+39 -13
View File
@@ -1,18 +1,44 @@
version: '3.8'
services:
prayer:
image: ghcr.io/nmemmert/prayer:latest
build:
context: .
ports:
- "3000:4000"
volumes:
- /media/ZimaOS-HD/AppData/Prayer/Data:/data
environment:
- DATA_DIR=/data
- PORT=4000
gracious_eldan:
image: ghcr.io/nmemmert/prayer-app:latest
container_name: prayer-app
restart: unless-stopped
ports:
- "3050:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_EMAILJS_SERVICE_ID=service_zwpcif9
- NEXT_PUBLIC_EMAILJS_TEMPLATE_ID=template_z50fk3e
- NEXT_PUBLIC_EMAILJS_PUBLIC_KEY=W6JhfYIWckVEP2nrm
- FIREBASE_PROJECT_ID=prayer-app-6701f
- FIREBASE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC55AHf+yi+v5O9\nEV6kfylFqbhI/NJ9ptPV+TeNVBhpgviZxqXVB3TqwyF/opWZa9FsYFyLr2echI65\nUxbCy3oSUMVKMdp5NWVo7PN4JcqrKmemLCEsQ40adMXNVs7K4bEokiN7en4G12sX\n+f8Uj2m+HtogJHU9/xJeqCUV+HxB927CRKsO+G8fPvlsHamJoo9lGNzAiuS8KIKc\noqblNzhit0xUzQgZO8QG6pfoJZ3OjOAeSea9woyqIzL8ANpYmGFsne0n4YnLkJpE\nu0ywEA8PVTCic/GU/Y7cRdtL+qL6WK7sNIFwE+U2tMS18tnGVtpFa46sYH64HTqD\nWY7k0IpFAgMBAAECggEAOP1KBdz+NNyO8pvspAnIIW9S7C12asdGkocBHPb3NQHO\nLXbVSGYs1fxUvMifjJWdcthoBvsTjMym5PIwPDAdkJfqpEB8dkSEfpKGYgNUCv3J\nIQzObr+W9tFuAvHmHBTtNz72WdjH4lkQoB0osqj1BjB3Nvt2ZjdY7mQmWoH/tkkb\nUNk/XTvpTSN51CV2qpI8qsRsUctqGu3yJZpZSoBcgsTdr6G0VUrZxLlg2Jsn2J/G\na9KKsBGWmUnFXNGxHiyxlOST+FGmq6W6TPhSAikhEVr3P47U+MtgoIN/P+veniwJ\nuRwMLUTHaqeYkB0+dkQ/TNVTU2B6UOBKCSZbCMQU0QKBgQDnhhkLOFpWY5hcvV2d\nMnFQbb62WJMdvQ0g7K0Jo5DvLb2qucmVoVoezjj3jPVqOUp3tiJt/bEWNJ4LA7J0\nRJI7k9+JW9wbw8sY40zpiuz6ZJ67iyS9fi9aKIFR09MOZ/TyybCF0SwKq86ABwPM\nXWlOsjyVgNmznjQUNPhsu7l/ywKBgQDNiuevP9HYZq5sRSiX/Fw4Odkq9vsKV9Zc\nSm4CT3Cuytjn1GiQMalKGbe45XUz/lrvN0QVFE+ORjgqE9b2NeSdmHLZDqnbie62\nMo96GVLyfP8GT4yCH0LmHV3lNvsbJVcl0qFFjhMqaa65zk45JQHcSmWOLzLU3o27\nNY3g/Ju8LwKBgQDFuB9rFZUQcb2Mdaucs5dRuGdGEh6RJwIvO/AKa0U6/TUct80P\nm9v6suKzhg2PyZ1u3nxIsn6pGAZgIagk4up/NqmQBILUs0Ce4H6uc5YtwABO6WVW\n7oxB0pWd6+gyRVVKgjPeE+QouNCBRXxY6u0j59MVWhSQ8GpJV3NQaIDiQQKBgBYy\nCsTE6n8oz2sHqNOIv9iefpICK6e5Rn+e1fRP6cYmR3f5WvU/N5212z4PITHlJKKy\nYBq6n8CoWoZIsCzUMwTFoplH2RXI9ksceJvhwjBHSS7ARlcC0zGQOER5RHJw/NEr\nxpiBOBabZO/RlGbdVS8ZpY/ODvonsjxmExUZkiOnAoGASzbkYljSHufy85NEcBL5\n2iP2FR/CgPQjBT99LNZ4VduetPEn76+geoYBY55CTbMgXdbf2R7/MjZfxhJz7ojU\nufivomI8Mzcxz4PCMQxVnsa7jy28u2xAxsapOdg2ftOilzTuLfInXGzLzLPeQAYK\n6VT67TPTfqKVe3bYM2VJXiw=\n-----END PRIVATE KEY-----\n
- FIREBASE_CLIENT_EMAIL=firebase-adminsdk-fbsvc@prayer-app-6701f.iam.gserviceaccount.com
networks:
- gracious_eldan_prayer-network
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/prayers"]
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 5s
timeout: 10s
retries: 3
start_period: 40s
networks:
gracious_eldan_prayer-network:
name: gracious_eldan_prayer-network
driver: bridge
x-casaos:
author: self
category: self
hostname: ""
icon: https://icon.casaos.io/main/all/prayer-app.png
index: /
is_uncontrolled: false
port_map: "3050"
scheme: http
store_app_id: gracious_eldan
title:
custom: ""
en_us: prayer-app
+13
View File
@@ -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}}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Additional ignores for generated/config files:
"public/sw.js",
"public/workbox-*.js",
"public/firebase-messaging-sw.js",
"jest.config.js",
"jest.setup.js",
"next-i18next.config.js",
"next.config.ts",
"src/dataconnect-generated/**",
]),
]);
export default eslintConfig;
+30
View File
@@ -0,0 +1,30 @@
{
"firestore": {
"rules": "firestore.rules"
},
"emulators": {
"dataconnect": {
"dataDir": "dataconnect/.dataconnect/pgliteData"
}
},
"dataconnect": {
"source": "dataconnect"
},
"functions": [
{
"source": "functions",
"codebase": "default",
"disallowLegacyRuntimeConfig": true,
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
"*.local"
],
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run build"
]
}
]
}
+87
View File
@@ -0,0 +1,87 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Allow authenticated users to read/write their own data
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
// Allow users to read/write their own statistics
match /statistics/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Allow users to read/write their own settings
match /settings/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Allow users to read/write their own drafts
match /drafts/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
// Allow public reading of answered prayers for testimony wall
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 delete: if request.auth != null &&
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 &&
resource.data.userId == request.auth.uid;
allow create: if request.auth != null &&
request.resource.data.userId == request.auth.uid;
}
// Allow server-side API access for scheduled emails (no auth required for GET)
match /prayers {
allow read: if true;
}
// Allow server-side API access for scheduled emails
match /users {
allow read: if true;
}
// Allow server-side API access to config (for SMTP settings)
match /config/{configId} {
allow read: if true;
allow write: if request.auth != null;
}
// Allow authenticated users to read/write shared prayers
match /sharedPrayers/{prayerId} {
allow read: if true; // Anyone can read shared prayers
allow create: if request.auth != null; // Only authenticated users can share
allow update: if request.auth != null &&
request.auth.uid == resource.data.userId; // Only the original sharer can update
allow delete: if request.auth != null &&
exists(/databases/$(database)/documents/config/admin) &&
request.auth.token.email in
get(/databases/$(database)/documents/config/admin).data.adminEmails; // Only admins can delete
}
}
}
+33
View File
@@ -0,0 +1,33 @@
module.exports = {
root: true,
env: {
es6: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"google",
"plugin:@typescript-eslint/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
project: ["tsconfig.json", "tsconfig.dev.json"],
sourceType: "module",
},
ignorePatterns: [
"/lib/**/*", // Ignore built files.
"/generated/**/*", // Ignore generated files.
],
plugins: [
"@typescript-eslint",
"import",
],
rules: {
"quotes": ["error", "double"],
"import/no-unresolved": 0,
"indent": ["error", 2],
},
};
+10
View File
@@ -0,0 +1,10 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
# TypeScript v1 declaration files
typings/
# Node.js dependency directory
node_modules/
*.local
+9929
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "functions",
"scripts": {
"lint": "eslint .",
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "22"
},
"main": "lib/index.js",
"dependencies": {
"firebase-admin": "^12.6.0",
"firebase-functions": "^6.0.1"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"eslint": "^8.9.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"firebase-functions-test": "^3.1.0",
"typescript": "^5.7.3"
},
"private": true
}
+387
View File
@@ -0,0 +1,387 @@
/**
* Import function triggers from their respective submodules:
*
* import {onCall} from "firebase-functions/v2/https";
* import {onDocumentWritten} from "firebase-functions/v2/firestore";
*
* See a full list of supported triggers at https://firebase.google.com/docs/functions
*/
import {setGlobalOptions} from "firebase-functions";
import {onRequest} from "firebase-functions/https";
import {onSchedule} from "firebase-functions/v2/scheduler";
import * as logger from "firebase-functions/logger";
import * as admin from "firebase-admin";
// Initialize Firebase Admin
admin.initializeApp();
// For cost control, you can set the maximum number of containers that can be
// running at the same time. This helps mitigate the impact of unexpected
// traffic spikes by instead downgrading performance. This limit is a
// per-function limit. You can override the limit for each function using the
// `maxInstances` option in the function's options, e.g.
// `onRequest({ maxInstances: 5 }, (req, res) => { ... })`.
// NOTE: setGlobalOptions does not apply to functions using the v1 API. V1
// functions should each use functions.runWith({ maxInstances: 10 }) instead.
// In the v1 API, each function can only serve one request per container, so
// this will be the maximum concurrent request count.
setGlobalOptions({ maxInstances: 10 });
// Daily prayer reminders function
export const sendDailyPrayerReminders = onSchedule(
{
schedule: "0 9 * * *", // Run daily at 9 AM UTC
timeZone: "America/New_York", // Adjust timezone as needed
retryCount: 3,
maxInstances: 1,
},
async (event) => {
logger.info("Starting daily prayer reminders", {structuredData: true});
try {
// Get all users who have notifications enabled
const usersRef = admin.firestore().collection('users');
const usersSnapshot = await usersRef
.where('notificationsEnabled', '==', true)
.where('fcmToken', '!=', null)
.get();
logger.info(`Found ${usersSnapshot.size} users with notifications enabled`);
let successCount = 0;
let failureCount = 0;
// Send notifications to each user
for (const userDoc of usersSnapshot.docs) {
const userData = userDoc.data();
const userId = userDoc.id;
const fcmToken = userData.fcmToken;
const reminderFrequency = userData.reminderFrequency || 'never';
// Skip if user doesn't want daily reminders
if (reminderFrequency !== 'daily') {
continue;
}
try {
// Get user's active prayers for summary
const prayersRef = admin.firestore().collection('prayers');
const activePrayersSnapshot = await prayersRef
.where('userId', '==', userId)
.where('archived', '==', false)
.where('answered', '==', false)
.get();
const activePrayersCount = activePrayersSnapshot.size;
const includeActiveSummary = userData.includeActiveSummary || false;
// Prepare notification message
let notificationBody = "Time for your daily prayer reflection!";
if (includeActiveSummary && activePrayersCount > 0) {
notificationBody += ` You have ${activePrayersCount} active prayer${activePrayersCount !== 1 ? 's' : ''} waiting for your attention.`;
}
// Send FCM message
const message = {
token: fcmToken,
notification: {
title: "Daily Prayer Reminder",
body: notificationBody,
},
data: {
type: 'daily_reminder',
userId: userId,
activePrayersCount: activePrayersCount.toString(),
},
webpush: {
fcmOptions: {
link: "/", // Link to your app
},
},
};
const response = await admin.messaging().send(message);
logger.info(`Successfully sent daily reminder to user ${userId}`, {
messageId: response,
activePrayersCount
});
successCount++;
// Update last email sent timestamp
await usersRef.doc(userId).update({
lastReminderSent: admin.firestore.FieldValue.serverTimestamp(),
});
} catch (error) {
logger.error(`Failed to send daily reminder to user ${userId}`, {
error: error instanceof Error ? error.message : String(error),
fcmToken: fcmToken.substring(0, 20) + '...' // Log partial token for debugging
});
failureCount++;
}
}
logger.info("Daily prayer reminders completed", {
totalUsers: usersSnapshot.size,
successCount,
failureCount,
structuredData: true
});
} catch (error) {
logger.error("Error in daily prayer reminders function", {
error: error instanceof Error ? error.message : String(error),
structuredData: true
});
throw error;
}
}
);
// Weekly prayer reminders function
export const sendWeeklyPrayerReminders = onSchedule(
{
schedule: "0 9 * * 1", // Run weekly on Monday at 9 AM UTC
timeZone: "America/New_York",
retryCount: 3,
maxInstances: 1,
},
async (event) => {
logger.info("Starting weekly prayer reminders", {structuredData: true});
try {
const usersRef = admin.firestore().collection('users');
const usersSnapshot = await usersRef
.where('notificationsEnabled', '==', true)
.where('fcmToken', '!=', null)
.where('reminderFrequency', '==', 'weekly')
.get();
logger.info(`Found ${usersSnapshot.size} users with weekly notifications enabled`);
let successCount = 0;
let failureCount = 0;
for (const userDoc of usersSnapshot.docs) {
const userData = userDoc.data();
const userId = userDoc.id;
const fcmToken = userData.fcmToken;
try {
const prayersRef = admin.firestore().collection('prayers');
const activePrayersSnapshot = await prayersRef
.where('userId', '==', userId)
.where('archived', '==', false)
.get();
const activePrayersCount = activePrayersSnapshot.size;
const answeredPrayersCount = activePrayersSnapshot.docs.filter(doc => doc.data().answered).length;
const message = {
token: fcmToken,
notification: {
title: "Weekly Prayer Reflection",
body: `This week: ${answeredPrayersCount} prayers answered, ${activePrayersCount - answeredPrayersCount} still active. Take time to reflect and pray!`,
},
data: {
type: 'weekly_reminder',
userId: userId,
activePrayersCount: (activePrayersCount - answeredPrayersCount).toString(),
answeredPrayersCount: answeredPrayersCount.toString(),
},
webpush: {
fcmOptions: {
link: "/",
},
},
};
const response = await admin.messaging().send(message);
logger.info(`Successfully sent weekly reminder to user ${userId}`, {
messageId: response,
activePrayersCount: activePrayersCount - answeredPrayersCount,
answeredPrayersCount
});
successCount++;
await usersRef.doc(userId).update({
lastReminderSent: admin.firestore.FieldValue.serverTimestamp(),
});
} catch (error) {
logger.error(`Failed to send weekly reminder to user ${userId}`, {
error: error instanceof Error ? error.message : String(error)
});
failureCount++;
}
}
logger.info("Weekly prayer reminders completed", {
totalUsers: usersSnapshot.size,
successCount,
failureCount,
structuredData: true
});
} catch (error) {
logger.error("Error in weekly prayer reminders function", {
error: error instanceof Error ? error.message : String(error),
structuredData: true
});
throw error;
}
}
);
// Monthly prayer reminders function
export const sendMonthlyPrayerReminders = onSchedule(
{
schedule: "0 9 1 * *", // Run monthly on the 1st at 9 AM UTC
timeZone: "America/New_York",
retryCount: 3,
maxInstances: 1,
},
async (event) => {
logger.info("Starting monthly prayer reminders", {structuredData: true});
try {
const usersRef = admin.firestore().collection('users');
const usersSnapshot = await usersRef
.where('notificationsEnabled', '==', true)
.where('fcmToken', '!=', null)
.where('reminderFrequency', '==', 'monthly')
.get();
logger.info(`Found ${usersSnapshot.size} users with monthly notifications enabled`);
let successCount = 0;
let failureCount = 0;
for (const userDoc of usersSnapshot.docs) {
const userData = userDoc.data();
const userId = userDoc.id;
const fcmToken = userData.fcmToken;
try {
const prayersRef = admin.firestore().collection('prayers');
const allPrayersSnapshot = await prayersRef
.where('userId', '==', userId)
.get();
const totalPrayers = allPrayersSnapshot.size;
const answeredPrayers = allPrayersSnapshot.docs.filter(doc => doc.data().answered).length;
const activePrayers = totalPrayers - answeredPrayers;
const message = {
token: fcmToken,
notification: {
title: "Monthly Prayer Review",
body: `This month: ${totalPrayers} total prayers, ${answeredPrayers} answered, ${activePrayers} still active. Reflect on God's faithfulness!`,
},
data: {
type: 'monthly_reminder',
userId: userId,
totalPrayers: totalPrayers.toString(),
answeredPrayers: answeredPrayers.toString(),
activePrayers: activePrayers.toString(),
},
webpush: {
fcmOptions: {
link: "/",
},
},
};
const response = await admin.messaging().send(message);
logger.info(`Successfully sent monthly reminder to user ${userId}`, {
messageId: response,
totalPrayers,
answeredPrayers,
activePrayers
});
successCount++;
await usersRef.doc(userId).update({
lastReminderSent: admin.firestore.FieldValue.serverTimestamp(),
});
} catch (error) {
logger.error(`Failed to send monthly reminder to user ${userId}`, {
error: error instanceof Error ? error.message : String(error)
});
failureCount++;
}
}
logger.info("Monthly prayer reminders completed", {
totalUsers: usersSnapshot.size,
successCount,
failureCount,
structuredData: true
});
} catch (error) {
logger.error("Error in monthly prayer reminders function", {
error: error instanceof Error ? error.message : String(error),
structuredData: true
});
throw error;
}
}
);
// Test function for manual triggering
export const testPrayerReminders = onRequest(async (req, res) => {
logger.info("Manual test of prayer reminders triggered");
try {
const usersRef = admin.firestore().collection('users');
const usersSnapshot = await usersRef
.where('notificationsEnabled', '==', true)
.where('fcmToken', '!=', null)
.limit(1) // Only test with first user
.get();
if (usersSnapshot.empty) {
res.json({ success: false, message: "No users with notifications enabled found" });
return;
}
const userDoc = usersSnapshot.docs[0];
const userData = userDoc.data();
const fcmToken = userData.fcmToken;
const message = {
token: fcmToken,
notification: {
title: "Test Prayer Reminder",
body: "This is a test notification from your Prayer App!",
},
data: {
type: 'test_reminder',
},
};
const response = await admin.messaging().send(message);
res.json({
success: true,
message: "Test notification sent successfully",
messageId: response,
userId: userDoc.id
});
} catch (error) {
logger.error("Error in test prayer reminders", { error: error instanceof Error ? error.message : String(error) });
res.status(500).json({
success: false,
message: "Failed to send test notification",
error: error instanceof Error ? error.message : String(error)
});
}
});
+5
View File
@@ -0,0 +1,5 @@
{
"include": [
".eslintrc.js"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "NodeNext",
"esModuleInterop": true,
"moduleResolution": "nodenext",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
},
"compileOnSave": true,
"include": [
"src"
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+19
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom'
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
i18n: {
defaultLocale: 'en',
locales: ['en', 'es', 'fr'], // Add more as needed
},
};
+16
View File
@@ -0,0 +1,16 @@
import type { NextConfig } from "next";
import withPWA from 'next-pwa';
const nextConfig: NextConfig = {
output: 'standalone',
images: {
domains: ['firebasestorage.googleapis.com'],
},
/* config options here */
};
const config = process.env.NODE_ENV === 'production' ? withPWA({
dest: 'public',
})(nextConfig) : nextConfig;
export default config;
+9598 -9492
View File
File diff suppressed because it is too large. Load diff
+45 -39
View File
@@ -1,47 +1,53 @@
{
"name": "prayer",
"version": "0.1.0",
"version": "0.4.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"cors": "^2.8.6",
"docx": "^9.7.1",
"express": "^5.2.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"test": "jest",
"test:watch": "jest --watch",
"version:minor": "npm version minor && node scripts/sync-version.js",
"version:major": "npm version major && node scripts/sync-version.js"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"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",
"lucide-react": "^0.562.0",
"next": "16.0.0",
"next-i18next": "^15.4.2",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"node-fetch": "^3.3.2",
"nodemailer": "^7.0.9",
"react": "19.2.0",
"react-csv": "^2.2.2",
"react-dom": "19.2.0",
"react-hot-toast": "^2.6.0"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"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/nodemailer": "^7.0.2",
"@types/react": "^19.2.7",
"@types/react-csv": "^1.1.10",
"@types/react-dom": "^19.2.3",
"baseline-browser-mapping": "^2.9.9",
"eslint": "^9",
"eslint-config-next": "16.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+31
View File
@@ -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);
});
+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+13
View File
@@ -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"
}
+20 -17
View File
@@ -1,25 +1,28 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"name": "Haver - Prayer Companion",
"short_name": "Haver",
"description": "Your spiritual friend and prayer companion",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"gcm_sender_id": "555011794707",
"gcm_user_visible_only": true,
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
"src": "/icon-192x192.png",
"sizes": "180x180",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
]
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
if(!self.define){let e,s={};const a=(a,n)=>(a=new URL(a+".js",n).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(n,i)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let c={};const o=e=>a(e,t),r={module:{uri:t},exports:c,require:o};s[t]=Promise.all(n.map(e=>r[e]||o(e))).then(e=>(i(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/RTXxDDjYPkCiNJ6w2qWiq/_buildManifest.js",revision:"f4d58a725936c94635b35b3f85e2a011"},{url:"/_next/static/RTXxDDjYPkCiNJ6w2qWiq/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/chunks/4bd1b696-b7529f9b306ab05d.js",revision:"b7529f9b306ab05d"},{url:"/_next/static/chunks/512-8d2b9de6fda25175.js",revision:"8d2b9de6fda25175"},{url:"/_next/static/chunks/7508b87c-dfcb7bb31c18c36f.js",revision:"dfcb7bb31c18c36f"},{url:"/_next/static/chunks/884-27d75bba95dbe213.js",revision:"27d75bba95dbe213"},{url:"/_next/static/chunks/995.a093d11da39e25d3.js",revision:"a093d11da39e25d3"},{url:"/_next/static/chunks/app/_global-error/page-48c825fda4ae9c10.js",revision:"48c825fda4ae9c10"},{url:"/_next/static/chunks/app/_not-found/page-f75e7ecfbcc8dd77.js",revision:"f75e7ecfbcc8dd77"},{url:"/_next/static/chunks/app/layout-5ea58a1aa81454ad.js",revision:"5ea58a1aa81454ad"},{url:"/_next/static/chunks/app/page-2a6751ff1202edf3.js",revision:"2a6751ff1202edf3"},{url:"/_next/static/chunks/dd8162e8-580391f39948d3f7.js",revision:"580391f39948d3f7"},{url:"/_next/static/chunks/framework-292291387d6b2e39.js",revision:"292291387d6b2e39"},{url:"/_next/static/chunks/main-5e55aa9a8969fd08.js",revision:"5e55aa9a8969fd08"},{url:"/_next/static/chunks/main-app-1640b70ccc992192.js",revision:"1640b70ccc992192"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-48c825fda4ae9c10.js",revision:"48c825fda4ae9c10"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-48c825fda4ae9c10.js",revision:"48c825fda4ae9c10"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-647436fbb68222b8.js",revision:"647436fbb68222b8"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-48c825fda4ae9c10.js",revision:"48c825fda4ae9c10"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-48c825fda4ae9c10.js",revision:"48c825fda4ae9c10"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-6240b410a738ea5b.js",revision:"6240b410a738ea5b"},{url:"/_next/static/css/c0d6f2e4d70567da.css",revision:"c0d6f2e4d70567da"},{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:"396ffcf00a63da03dfc03d78d4815494"},{url:"/globe.svg",revision:"2aaafa6a49b6563925fe440891e32717"},{url:"/icon-192x192.png",revision:"bbbd7efc0e31088ae0a3aeede7608534"},{url:"/icon-512x512.png",revision:"400538d76556a3a8ee4fd3dfda7bc092"},{url:"/locales/en/common.json",revision:"41332abbb11f0b33b3eb308a5448f8d6"},{url:"/manifest.json",revision:"6dbf565091414e98afd54d79577bb36d"},{url:"/next.svg",revision:"8e061864f388b47f33a1c3780831193e"},{url:"/vercel.svg",revision:"c0af2f507b369b085b35ef4bbe3bcf1e"},{url:"/window.svg",revision:"a2760511c65806022ad20adf74370ff3"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:n})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assLine truncated
+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+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
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const pkgPath = path.join(__dirname, '..', 'package.json');
const versionPath = path.join(__dirname, '..', 'src', 'lib', 'version.ts');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const version = pkg.version;
const content = `export const APP_VERSION = '${version}';\n\nexport const WHATS_NEW_BRIEF: string[] = [\n 'Firebase Migration: all data now syncs across devices',\n 'Calendar: persistent prayer tracking with multi-day support',\n 'Loading UX: smooth auth and data loading transitions',\n 'Offline Support: IndexedDB persistence for reliability'\n];\n`;
fs.writeFileSync(versionPath, content, 'utf8');
console.log('Synced version to src/lib/version.ts:', version);
+131 -3
View File
@@ -3,16 +3,16 @@ const cors = require('cors');
const fs = require('fs');
const path = require('path');
const { randomUUID } = require('crypto');
const cron = require('node-cron');
const {
Document, Packer, Paragraph, TextRun, HeadingLevel,
AlignmentType, BorderStyle, ShadingType, TableRow, TableCell,
Table, WidthType, PageBreak, Header, Footer, PageNumber,
NumberFormat,
AlignmentType, BorderStyle, PageBreak, Footer, PageNumber,
} = require('docx');
const app = express();
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const DATA_FILE = path.join(DATA_DIR, 'prayers.json');
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
const PORT = process.env.PORT || 4000;
try {
@@ -42,10 +42,90 @@ function writePrayers(prayers) {
fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2));
}
function readSettings() {
if (!fs.existsSync(SETTINGS_FILE)) return {};
try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } catch { return {}; }
}
function writeSettings(s) {
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2));
}
function fmtDate(iso) {
return iso ? new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '';
}
async function sendNtfy(topic, title, message, tags = []) {
if (!topic) return;
const url = topic.startsWith('http') ? topic : `https://ntfy.sh/${topic}`;
// HTTP headers must be ASCII-only; replace non-ASCII characters
const safeTitle = title.replace(/[^\x00-\x7F]/g, '-');
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Title': safeTitle,
'Content-Type': 'text/plain',
...(tags.length ? { 'Tags': tags.join(',') } : {}),
},
body: message,
});
if (!res.ok) console.error('ntfy error:', res.status, await res.text());
else console.log(`ntfy sent: "${title}"`);
} catch (err) {
console.error('ntfy send failed:', err.message);
}
}
async function sendDailyDigest() {
const settings = readSettings();
if (!settings.ntfyTopic) return;
const prayers = readPrayers().filter(p => p.status === 'active');
if (!prayers.length) return;
const lines = prayers.map((p, i) => {
const who = p.person ? `${p.person}: ` : '';
return `${i + 1}. ${who}${p.request}`;
});
await sendNtfy(
settings.ntfyTopic,
`Prayer Journal -${prayers.length} active prayer${prayers.length !== 1 ? 's' : ''}`,
lines.join('\n'),
['pray', 'raised_hands']
);
}
async function checkPrayerReminders() {
const settings = readSettings();
if (!settings.ntfyTopic) return;
const prayers = readPrayers().filter(p => p.status === 'active' && p.reminderDays && p.reminderDays.length);
if (!prayers.length) return;
const now = new Date();
const dayName = ['sun','mon','tue','wed','thu','fri','sat'][now.getDay()];
const currentHHMM = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
const reminderTime = settings.reminderTime || '08:00';
if (currentHHMM !== reminderTime) return;
for (const prayer of prayers) {
if ((prayer.reminderDays || []).includes(dayName)) {
const title = prayer.person ? `Praying for ${prayer.person}` : 'Prayer reminder';
await sendNtfy(settings.ntfyTopic, title, prayer.request, ['pray', 'raised_hands']);
}
}
}
// Cron: every minute checks whether it's time for digest or per-prayer reminders
cron.schedule('* * * * *', async () => {
const settings = readSettings();
if (!settings.ntfyTopic) return;
const now = new Date();
const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
if (settings.dailyDigestTime && hhmm === settings.dailyDigestTime) await sendDailyDigest();
if (settings.reminderTime) await checkPrayerReminders();
});
console.log('Notification cron scheduled.');
function prayerToSection(prayer, index) {
const paras = [];
@@ -94,6 +174,33 @@ function prayerToSection(prayer, index) {
}
}
// Notes (if present)
if (prayer.notes && prayer.notes.trim()) {
paras.push(new Paragraph({
children: [new TextRun({ text: 'Notes', bold: true, size: 20, color: '5A5A2A' })],
spacing: { after: 60 },
}));
paras.push(new Paragraph({
children: [new TextRun({ text: prayer.notes, size: 22 })],
spacing: { after: 160 },
}));
}
// Prayer log (if present)
if (prayer.prayedLog && prayer.prayedLog.length > 0) {
paras.push(new Paragraph({
children: [new TextRun({ text: `Prayer Log (${prayer.prayedLog.length} session${prayer.prayedLog.length !== 1 ? 's' : ''})`, bold: true, size: 20, color: '4B6FA8' })],
spacing: { after: 60 },
}));
const sorted = [...prayer.prayedLog].sort((a, b) => new Date(b.date) - new Date(a.date));
sorted.forEach(entry => {
const runs = [new TextRun({ text: fmtDate(entry.date), bold: true, size: 19, color: '555555' })];
if (entry.note) runs.push(new TextRun({ text: ` — ${entry.note}`, size: 19, color: '666666' }));
paras.push(new Paragraph({ children: runs, spacing: { after: 60 } }));
});
paras.push(new Paragraph({ children: [], spacing: { after: 80 } }));
}
// Divider paragraph with bottom border
paras.push(new Paragraph({
children: [],
@@ -104,6 +211,27 @@ function prayerToSection(prayer, index) {
return paras;
}
app.get('/api/settings', (req, res) => {
res.json(readSettings());
});
app.put('/api/settings', (req, res) => {
try {
const updated = { ...readSettings(), ...req.body };
writeSettings(updated);
res.json(updated);
} catch (err) {
res.status(500).json({ error: 'Could not save settings.' });
}
});
app.post('/api/notifications/test', async (req, res) => {
const settings = readSettings();
if (!settings.ntfyTopic) return res.status(400).json({ error: 'No ntfy topic configured.' });
await sendNtfy(settings.ntfyTopic, 'Prayer Journal -Test', 'Notifications are working! 🙏', ['white_check_mark']);
res.json({ ok: true });
});
app.get('/api/export', (req, res) => {
try {
const all = readPrayers();
+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")');
+149
View File
@@ -630,6 +630,22 @@ mark.highlight {
padding: 0.1rem 0.4rem;
}
/* Notes section */
.notes-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.notes-text {
font-size: 0.93rem;
line-height: 1.6;
white-space: pre-wrap;
color: var(--text);
}
/* Prayer log section inside card */
.prayed-log-section {
display: flex;
@@ -896,3 +912,136 @@ mark.highlight {
font-size: 0.92rem;
line-height: 1.4;
}
/* Settings panel */
.settings-btn {
margin-top: 0.5rem;
font-size: 0.85rem;
}
.settings-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
box-shadow: var(--shadow);
margin-bottom: 1.5rem;
}
.settings-title {
font-size: 1.1rem;
font-weight: normal;
margin-bottom: 0.5rem;
}
.settings-desc {
font-size: 0.88rem;
color: var(--text-muted);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
margin-bottom: 1rem;
line-height: 1.5;
}
.settings-desc a { color: var(--primary); }
.settings-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.settings-label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
}
.settings-input {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
background: var(--bg);
color: var(--text);
font-family: inherit;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
.settings-input:focus { border-color: var(--primary); }
.settings-hint {
font-size: 0.78rem;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
font-style: italic;
}
.settings-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.settings-msg {
font-size: 0.85rem;
color: var(--answered);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Reminder day picker */
.reminder-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
flex-wrap: wrap;
}
.reminder-label {
font-size: 0.8rem;
color: var(--text-muted);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
white-space: nowrap;
}
.day-picker {
display: flex;
gap: 0.3rem;
}
.day-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-muted);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.72rem;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.day-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.day-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
+199 -14
View File
@@ -1,11 +1,23 @@
import React, { useState, useMemo } from 'react';
import { usePrayers } from './usePrayers';
import { Prayer, PrayedEntry } from './types';
import { useSettings } from './useSettings';
import { Prayer, PrayedEntry, AppSettings } from './types';
import './App.css';
type Filter = 'active' | 'answered' | 'all';
type Tab = 'prayers' | 'calendar';
const DOT_PALETTE = [
'#c07a3a', '#3d6b52', '#6b4545', '#456b8a',
'#7a5c8a', '#8a7a3a', '#3a6b6b', '#8a4a6b',
];
function prayerColor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffffffff;
return DOT_PALETTE[Math.abs(hash) % DOT_PALETTE.length];
}
function normalize(s: string) {
return s.toLowerCase().trim();
}
@@ -21,6 +33,9 @@ function matches(prayer: Prayer, query: string) {
);
}
const DAYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
const DAY_LABELS: Record<string, string> = { sun:'Su', mon:'Mo', tue:'Tu', wed:'We', thu:'Th', fri:'Fr', sat:'Sa' };
export default function App() {
const {
prayers,
@@ -33,9 +48,13 @@ export default function App() {
archivePrayer,
deletePrayer,
updateRequest,
updateNotes,
logPrayed,
updateReminderDays,
} = usePrayers();
const { settings, saveSettings, testNotification } = useSettings();
const [showSettings, setShowSettings] = useState(false);
const [tab, setTab] = useState<Tab>('prayers');
const [filter, setFilter] = useState<Filter>('active');
const [activeTag, setActiveTag] = useState<string | null>(null);
@@ -126,8 +145,19 @@ export default function App() {
<header className="header">
<h1>Prayer Journal</h1>
<p className="subtitle">Bring your requests. Record His faithfulness.</p>
<button className="btn ghost settings-btn" onClick={() => setShowSettings(s => !s)} title="Notification settings">
🔔 {showSettings ? 'Close settings' : 'Notifications'}
</button>
</header>
{showSettings && (
<SettingsPanel
settings={settings}
onSave={saveSettings}
onTest={testNotification}
/>
)}
<div className="tab-bar">
<button
className={`tab-btn ${tab === 'prayers' ? 'active' : ''}`}
@@ -249,6 +279,7 @@ export default function App() {
editingAnswer={editingAnswer?.id === prayer.id ? editingAnswer.text : null}
activeTag={activeTag}
searchQuery={search}
ntfyConfigured={!!settings.ntfyTopic}
onTagClick={handleTagClick}
onToggle={() => setExpandedId(expandedId === prayer.id ? null : prayer.id)}
onStartAnswer={() => {
@@ -262,7 +293,9 @@ export default function App() {
onArchive={() => archivePrayer(prayer.id)}
onDelete={() => deletePrayer(prayer.id)}
onEditRequest={text => updateRequest(prayer.id, text)}
onLogPrayed={(entry) => logPrayed(prayer.id, entry)}
onLogPrayed={entry => logPrayed(prayer.id, entry)}
onSaveNotes={notes => updateNotes(prayer.id, notes)}
onUpdateReminderDays={days => updateReminderDays(prayer.id, days)}
formatDate={formatDate}
/>
))}
@@ -290,12 +323,90 @@ function highlight(text: string, query: string) {
);
}
function SettingsPanel({
settings,
onSave,
onTest,
}: {
settings: AppSettings;
onSave: (s: Partial<AppSettings>) => Promise<AppSettings>;
onTest: () => Promise<void>;
}) {
const [topic, setTopic] = useState(settings.ntfyTopic || '');
const [digest, setDigest] = useState(settings.dailyDigestTime || '');
const [remTime, setRemTime] = useState(settings.reminderTime || '08:00');
const [msg, setMsg] = useState('');
const [testing, setTesting] = useState(false);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
try {
await onSave({ ntfyTopic: topic.trim() || undefined, dailyDigestTime: digest || undefined, reminderTime: remTime || '08:00' });
setMsg('Settings saved.');
} catch { setMsg('Failed to save.'); }
setTimeout(() => setMsg(''), 3000);
}
async function handleTest() {
setTesting(true);
try { await onTest(); setMsg('Test notification sent!'); }
catch (err: any) { setMsg(err.message); }
setTesting(false);
setTimeout(() => setMsg(''), 4000);
}
return (
<div className="settings-panel">
<h2 className="settings-title">Notification Settings</h2>
<p className="settings-desc">
Notifications are sent via <a href="https://ntfy.sh" target="_blank" rel="noreferrer">ntfy.sh</a> — a free, open-source push service.
Install the ntfy app on your phone and subscribe to your topic to receive notifications.
</p>
<form onSubmit={handleSave} className="settings-form">
<label className="settings-label">
ntfy topic
<input
className="settings-input"
placeholder="e.g. my-prayer-journal-abc123"
value={topic}
onChange={e => setTopic(e.target.value)}
/>
<span className="settings-hint">Pick a unique topic name. Anyone who knows it can subscribe, so make it hard to guess.</span>
</label>
<label className="settings-label">
Daily digest time
<input type="time" className="settings-input" value={digest} onChange={e => setDigest(e.target.value)} />
<span className="settings-hint">Receive a summary of all active prayers at this time each day. Leave blank to disable.</span>
</label>
<label className="settings-label">
Per-prayer reminder time
<input type="time" className="settings-input" value={remTime} onChange={e => setRemTime(e.target.value)} />
<span className="settings-hint">Time of day reminders fire for individual prayers that have reminder days set.</span>
</label>
<div className="settings-actions">
<button className="btn btn-primary btn-sm" type="submit">Save</button>
{topic && (
<button type="button" className="btn btn-sm" onClick={handleTest} disabled={testing}>
{testing ? 'Sending…' : 'Send test'}
</button>
)}
{msg && <span className="settings-msg">{msg}</span>}
</div>
</form>
</div>
);
}
interface CardProps {
prayer: Prayer;
expanded: boolean;
editingAnswer: string | null;
activeTag: string | null;
searchQuery: string;
ntfyConfigured: boolean;
onTagClick: (tag: string) => void;
onToggle: () => void;
onStartAnswer: () => void;
@@ -307,6 +418,8 @@ interface CardProps {
onDelete: () => void;
onEditRequest: (text: string) => void;
onLogPrayed: (entry: PrayedEntry) => void;
onSaveNotes: (notes: string) => void;
onUpdateReminderDays: (days: string[]) => void;
formatDate: (iso: string) => string;
}
@@ -316,6 +429,7 @@ function PrayerCard({
editingAnswer,
activeTag,
searchQuery,
ntfyConfigured,
onTagClick,
onToggle,
onStartAnswer,
@@ -326,11 +440,14 @@ function PrayerCard({
onArchive,
onDelete,
onLogPrayed,
onSaveNotes,
onUpdateReminderDays,
formatDate,
}: CardProps) {
const [logDate, setLogDate] = useState(new Date().toISOString().slice(0, 10));
const [logNote, setLogNote] = useState('');
const [showLogForm, setShowLogForm] = useState(false);
const [editingNotes, setEditingNotes] = useState<string | null>(null);
function handleLogSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -339,15 +456,29 @@ function PrayerCard({
setShowLogForm(false);
}
function handleNotesSave() {
if (editingNotes === null) return;
onSaveNotes(editingNotes);
setEditingNotes(null);
}
function toggleDay(day: string) {
const current = prayer.reminderDays || [];
const next = current.includes(day) ? current.filter(d => d !== day) : [...current, day];
onUpdateReminderDays(next);
}
const sortedLog = [...(prayer.prayedLog ?? [])].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
const color = prayerColor(prayer.id);
return (
<li className={`prayer-card ${prayer.status}`}>
<li className={`prayer-card ${prayer.status}`} style={{ borderLeftColor: color }}>
<div className="card-top" onClick={onToggle}>
<div className="card-main">
<span className={`status-dot ${prayer.status}`} />
<span className="status-dot" style={{ background: color }} />
<div className="card-text">
{prayer.person && (
<p className="person-name">{highlight(prayer.person, searchQuery)}</p>
@@ -411,14 +542,44 @@ function PrayerCard({
</div>
)}
{/* Notes section */}
<div className="notes-section">
<div className="prayed-log-header">
<label className="answer-label">Notes</label>
{editingNotes === null && (
<button className="btn btn-sm" onClick={() => setEditingNotes(prayer.notes ?? '')}>
{prayer.notes ? 'Edit' : '+ Add notes'}
</button>
)}
</div>
{editingNotes !== null ? (
<>
<textarea
className="log-note-input"
placeholder="Scripture, updates, mid-prayer thoughts…"
value={editingNotes}
onChange={e => setEditingNotes(e.target.value)}
rows={4}
autoFocus
/>
<div className="card-actions">
<button className="btn btn-primary btn-sm" onClick={handleNotesSave}>Save</button>
<button className="btn btn-sm" onClick={() => setEditingNotes(null)}>Cancel</button>
</div>
</>
) : prayer.notes ? (
<p className="notes-text">{prayer.notes}</p>
) : (
<p className="prayed-log-empty">No notes yet.</p>
)}
</div>
{/* Prayer log section */}
<div className="prayed-log-section">
<div className="prayed-log-header">
<label className="answer-label">Prayer Log</label>
{!showLogForm && (
<button
className="btn btn-sm btn-primary"
onClick={() => setShowLogForm(true)}
>
<button className="btn btn-sm btn-primary" onClick={() => setShowLogForm(true)}>
+ I Prayed This
</button>
)}
@@ -469,6 +630,24 @@ function PrayerCard({
<button className="btn btn-sm ghost" onClick={onArchive}>Archive</button>
<button className="btn btn-sm ghost danger" onClick={onDelete}>Delete</button>
</div>
{ntfyConfigured && prayer.status === 'active' && (
<div className="reminder-row">
<span className="reminder-label">🔔 Remind me</span>
<div className="day-picker">
{DAYS.map(d => (
<button
key={d}
type="button"
className={`day-btn ${(prayer.reminderDays || []).includes(d) ? 'active' : ''}`}
onClick={() => toggleDay(d)}
>
{DAY_LABELS[d]}
</button>
))}
</div>
</div>
)}
</div>
)}
</li>
@@ -552,19 +731,25 @@ function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate:
day === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSelected = selectedDate === dateStr;
const uniqueColors = entries
? Array.from(new Map(entries.map(e => [e.prayer.id, prayerColor(e.prayer.id)])).values())
: [];
return (
<div
key={day}
className={`cal-cell ${isToday ? 'cal-today' : ''} ${isSelected ? 'cal-selected' : ''} ${entries ? 'cal-has-prayers' : ''}`}
onClick={() => setSelectedDate(isSelected ? null : dateStr)}
onClick={() => entries && setSelectedDate(isSelected ? null : dateStr)}
>
<span className="cal-day-num">{day}</span>
{entries && (
{uniqueColors.length > 0 && (
<span className="cal-dot-row">
{entries.slice(0, 3).map((_, di) => (
<span key={di} className="cal-dot" />
{uniqueColors.slice(0, 5).map((color, di) => (
<span key={di} className="cal-dot" style={{ background: color }} />
))}
{entries.length > 3 && <span className="cal-dot-more">+{entries.length - 3}</span>}
{uniqueColors.length > 5 && (
<span className="cal-dot-more">+{uniqueColors.length - 5}</span>
)}
</span>
)}
</div>
@@ -582,7 +767,7 @@ function CalendarView({ prayers, formatDate }: { prayers: Prayer[]; formatDate:
) : (
<ul className="cal-detail-list">
{selectedEntries.map(({ prayer, entry }, i) => (
<li key={i} className="cal-detail-entry">
<li key={i} className="cal-detail-entry" style={{ borderLeftColor: prayerColor(prayer.id) }}>
<div className="cal-detail-prayer">
{prayer.person && <span className="person-name">{prayer.person} — </span>}
<span className="request-text">{prayer.request}</span>
+7
View File
@@ -0,0 +1,7 @@
import { render, screen } from '@testing-library/react';
import Profile from '../components/Profile';
test('renders profile component', () => {
render(<Profile />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+42
View File
@@ -0,0 +1,42 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
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;
}
+79
View File
@@ -0,0 +1,79 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from 'next-themes';
import { AuthProvider } from '@/context/AuthContext';
import { Toaster } from 'react-hot-toast';
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Haver - Prayer Companion",
description: "Your spiritual friend and prayer companion. Track your prayer journey and build a deeper connection with God.",
manifest: "/manifest.json",
icons: {
icon: "/icon-192x192.png",
apple: "/icon-192x192.png",
},
};
// Add global CSS variable definitions
const themeStylesheet = `
:root {
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
}
html.dark {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--border-color: #334155;
}
`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark" suppressHydrationWarning={true}>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<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>
</body>
</html>
);
}
+385
View File
@@ -0,0 +1,385 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
import Login from '@/components/Login';
import BottomNav from '@/components/BottomNav';
import ReminderBanner from '@/components/ReminderBanner';
import DailyEntry from '@/components/DailyEntry';
import CalendarView from '@/components/CalendarView';
import ReflectionsList from '@/components/ReflectionsList';
import ThingsToPrayFor from '@/components/ThingsToPrayFor';
import AboutPage from '@/components/AboutPage';
import HomePage from '@/components/HomePage';
import AnsweredPrayers from '@/components/AnsweredPrayers';
import { db } from '@/lib/firebase';
import { collection, addDoc, getDocs, updateDoc, query, where, doc, setDoc, getDoc } from 'firebase/firestore';
import { Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
interface Prayer {
id: string;
userId: string;
date: string;
text: string;
prayFor?: string;
answered?: boolean;
answerDetails?: string;
prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string - kept for backward compatibility
prayedDates?: string[]; // Array of dates when prayed for
}
const getLocalISODate = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export default function Home() {
const [activeTab, setActiveTab] = useState<'home' | 'compose' | 'journal' | 'answered' | 'praying' | 'reflections' | 'about'>('home');
const [prayers, setPrayers] = useState<Prayer[]>([]);
const [editingPrayer, setEditingPrayer] = useState<Prayer | null>(null);
const [prayersLoading, setPrayersLoading] = useState(false);
const [mostPrayedFor, setMostPrayedFor] = useState<string>('');
// Form state
const [date, setDate] = useState(getLocalISODate());
const [text, setText] = useState('');
const [prayFor, setPrayFor] = useState('');
const [answered, setAnswered] = useState(false);
const [answerDetails, setAnswerDetails] = useState('');
const { user, loading: authLoading } = useAuth();
// Load prayers from Firestore
useEffect(() => {
if (!user) {
setPrayers([]);
setPrayersLoading(false);
return;
}
setPrayersLoading(true);
const loadPrayers = async () => {
try {
const prayersRef = collection(db, 'prayers');
const q = query(prayersRef, where('userId', '==', user.uid));
const querySnapshot = await getDocs(q);
const loadedPrayers: Prayer[] = [];
querySnapshot.forEach((doc) => {
loadedPrayers.push({ id: doc.id, ...doc.data() } as Prayer);
});
setPrayers(loadedPrayers);
} catch (error) {
console.error('Error loading prayers:', error);
toast.error('Failed to load prayers');
} finally {
setPrayersLoading(false);
}
};
loadPrayers();
fetchPrayerStatistics();
}, [user]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast.error('Please log in to save prayers');
return;
}
if (!text.trim()) {
toast.error('Please write something');
return;
}
try {
const prayerData: any = {
userId: user.uid,
date,
text,
answered,
};
// Only add optional fields if they have values
if (prayFor) {
prayerData.prayFor = prayFor;
}
if (answerDetails) {
prayerData.answerDetails = answerDetails;
}
if (editingPrayer) {
// Update existing prayer
await updateDoc(doc(db, 'prayers', editingPrayer.id), prayerData);
setPrayers(prev =>
prev.map(p => (p.id === editingPrayer.id ? { ...p, ...prayerData } : p))
);
toast.success('Prayer updated!');
setEditingPrayer(null);
} else {
// Add new prayer
const docRef = await addDoc(collection(db, 'prayers'), prayerData);
const newPrayer: Prayer = { id: docRef.id, ...prayerData };
setPrayers(prev => [...prev, newPrayer]);
toast.success('Prayer saved!');
}
// Reset form
setDate(getLocalISODate());
setText('');
setPrayFor('');
setAnswered(false);
setAnswerDetails('');
} catch (error) {
console.error('Error saving prayer:', error);
toast.error('Failed to save prayer');
}
};
const handleSelectPrayer = (prayer: Prayer) => {
setEditingPrayer(prayer);
setDate(prayer.date);
setText(prayer.text);
setPrayFor(prayer.prayFor || '');
setAnswered(prayer.answered || false);
setAnswerDetails(prayer.answerDetails || '');
setActiveTab('compose');
};
const handleDateClick = (clickedDate: string) => {
const dayPrayers = prayers.filter(p => p.date === clickedDate);
if (dayPrayers.length > 0) {
handleSelectPrayer(dayPrayers[0]);
} else {
setDate(clickedDate);
setActiveTab('home');
}
};
const updatePrayerStatistics = async (prayFor: string) => {
if (!user) return;
try {
const statsDocRef = doc(db, 'users', user.uid, 'statistics', 'prayerStats');
// Get current stats
let stats: Record<string, number> = {};
try {
const statsSnap = await getDoc(statsDocRef);
if (statsSnap.exists()) {
stats = statsSnap.data() as Record<string, number>;
}
} catch (e) {
// Stats don't exist yet
}
// Increment count for this prayFor item
stats[prayFor] = (stats[prayFor] || 0) + 1;
// Update the stats document
await setDoc(statsDocRef, stats, { merge: true });
// Update local state
const sorted = Object.entries(stats).sort((a, b) => b[1] - a[1]);
setMostPrayedFor(sorted.length > 0 ? sorted[0][0] : '');
} catch (error) {
console.error('Error updating statistics:', error);
}
};
const fetchPrayerStatistics = async () => {
if (!user) return;
try {
const statsDocRef = doc(db, 'users', user.uid, 'statistics', 'prayerStats');
const statsSnap = await getDoc(statsDocRef);
if (statsSnap.exists()) {
const stats = statsSnap.data() as Record<string, number>;
const sorted = Object.entries(stats).sort((a, b) => b[1] - a[1]);
setMostPrayedFor(sorted[0][0]);
}
} catch (error: any) {
// Silently fail if offline - will retry when connection is restored
if (error?.code !== 'unavailable') {
console.error('Error fetching statistics:', error);
}
}
};
const handleMarkPrayed = async (prayerId: string) => {
if (!user) return;
try {
const today = getLocalISODate();
const prayer = prayers.find(p => p.id === prayerId);
if (!prayer) return;
// Get existing prayed dates or initialize array
const existingDates = prayer.prayedDates || [];
const updatedDates = [...existingDates, today];
await updateDoc(doc(db, 'prayers', prayerId), {
lastPrayedFor: today,
prayedDates: updatedDates,
});
// Update statistics if this prayer has a prayFor value
if (prayer.prayFor) {
await updatePrayerStatistics(prayer.prayFor);
}
setPrayers(prev =>
prev.map(p => (p.id === prayerId ? { ...p, lastPrayedFor: today, prayedDates: updatedDates } : p))
);
toast.success('Prayed!');
} catch (error) {
console.error('Error marking prayed:', error);
toast.error('Failed to update');
}
};
if (authLoading || prayersLoading) {
const loadingMessage = authLoading ? 'Preparing your sacred space...' : 'Loading your prayers...';
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center p-4 overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
<div className="absolute top-1/2 left-1/2 w-64 h-64 bg-pink-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '2s' }}></div>
</div>
<div className="text-center relative z-10">
{/* Animated Icon */}
<div className="mb-8 relative">
<div className="w-32 h-32 mx-auto relative">
{/* Outer rotating ring */}
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
<div className="absolute inset-0 border-4 border-transparent border-t-blue-400 border-r-blue-400 rounded-full animate-spin"></div>
{/* Middle rotating ring - reverse direction */}
<div className="absolute inset-3 border-4 border-purple-500/20 rounded-full"></div>
<div
className="absolute inset-3 border-4 border-transparent border-b-purple-400 border-l-purple-400 rounded-full animate-spin"
style={{ animationDirection: 'reverse', animationDuration: '2s' }}
></div>
{/* Inner pulsing circle */}
<div className="absolute inset-6 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-full animate-pulse"></div>
{/* App Icon */}
<div className="absolute inset-0 flex items-center justify-center">
<img
src="/icon-192x192.png"
alt="Haver icon"
className="w-16 h-16 rounded-lg shadow-2xl animate-pulse"
/>
</div>
</div>
</div>
{/* App Title */}
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent mb-3 animate-pulse">
Haver
</h1>
{/* Loading Message */}
<p className="text-gray-300 text-lg mb-6 animate-pulse">
{loadingMessage}
</p>
{/* Inspirational Quote */}
<div className="max-w-md mx-auto mb-8 px-4">
<blockquote className="text-gray-400 italic text-sm leading-relaxed">
"Be anxious for nothing, but in everything by prayer and supplication, with thanksgiving, let your requests be made known to God."
</blockquote>
<cite className="text-gray-600 text-xs mt-2 block">— Philippians 4:6</cite>
</div>
{/* Animated Dots */}
<div className="flex justify-center space-x-3">
<div className="w-3 h-3 bg-blue-500 rounded-full animate-bounce"></div>
<div className="w-3 h-3 bg-purple-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-3 h-3 bg-pink-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
);
}
if (!user) {
return <Login />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 md:pb-0" style={{ paddingBottom: 'calc(6rem + var(--safe-area-inset-bottom))' }}>
<ReminderBanner userEmail={user.email || undefined} />
<BottomNav activeTab={activeTab} onTabChange={setActiveTab} />
{activeTab === 'home' && (
<HomePage
prayers={prayers}
onStartPraying={() => setActiveTab('compose')}
onSelectPrayer={handleSelectPrayer}
onGoToPraying={() => setActiveTab('praying')}
onGoToAnswered={() => setActiveTab('answered')}
onGoToJournal={() => setActiveTab('journal')}
onGoToReflections={() => setActiveTab('reflections')}
userName={user.displayName || user.email || undefined}
mostPrayedFor={mostPrayedFor}
/>
)}
{activeTab === 'compose' && (
<DailyEntry
date={date}
onDateChange={setDate}
text={text}
onTextChange={setText}
prayFor={prayFor}
onPrayForChange={setPrayFor}
prayForOptions={Array.from(new Set(prayers.map(p => p.prayFor).filter(Boolean))) as string[]}
answered={answered}
onAnsweredChange={setAnswered}
answerDetails={answerDetails}
onAnswerDetailsChange={setAnswerDetails}
onSubmit={handleSubmit}
isEditing={!!editingPrayer}
/>
)}
{activeTab === 'journal' && (
<CalendarView prayers={prayers} onDayClick={handleDateClick} />
)}
{activeTab === 'answered' && (
<AnsweredPrayers prayers={prayers} onEdit={handleSelectPrayer} />
)}
{activeTab === 'praying' && (
<ThingsToPrayFor prayers={prayers.filter(p => !p.answered)} onEdit={handleSelectPrayer} onMarkPrayed={handleMarkPrayed} />
)}
{activeTab === 'reflections' && (
<ReflectionsList
prayers={prayers}
onPrayerClick={handleSelectPrayer}
onEdit={handleSelectPrayer}
/>
)}
{activeTab === 'about' && <AboutPage userEmail={user.email || undefined} />}
</div>
);
}
+606
View File
@@ -0,0 +1,606 @@
'use client';
import React, { useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { Info, Smartphone, BellRing } from 'lucide-react';
import { APP_VERSION, WHATS_NEW_BRIEF } from '@/lib/version';
import { db } from '@/lib/firebase';
import { doc, getDoc, setDoc } from 'firebase/firestore';
interface AboutPageProps {
userEmail?: string;
}
type Platform = 'ios' | 'android' | 'windows' | 'linux' | 'macos' | 'other';
export default function AboutPage({ userEmail }: AboutPageProps) {
const { logout, deleteAccount, sendVerificationEmail, user } = useAuth();
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteStep, setDeleteStep] = useState(0); // 0=initial, 1=first confirm, 2=second confirm, 3=type DELETE, 4=type email
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [deleteEmail, setDeleteEmail] = useState('');
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteError, setDeleteError] = useState('');
const [verificationMessage, setVerificationMessage] = useState('');
const [sendingVerification, setSendingVerification] = useState(false);
const [notificationsEnabled, setNotificationsEnabled] = useState(false);
const [reminderTime, setReminderTime] = useState('09:00');
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>('default');
const [platform, setPlatform] = useState<Platform>('other');
const [supportsWebPush, setSupportsWebPush] = useState(true);
React.useEffect(() => {
// Detect platform
const userAgent = navigator.userAgent.toLowerCase();
let detectedPlatform: Platform = 'other';
let webPushSupport = true;
if (userAgent.includes('iphone') || userAgent.includes('ipad')) {
detectedPlatform = 'ios';
webPushSupport = false; // iOS Safari doesn't support Web Push
} else if (userAgent.includes('android')) {
detectedPlatform = 'android';
} else if (userAgent.includes('windows')) {
detectedPlatform = 'windows';
} else if (userAgent.includes('linux')) {
detectedPlatform = 'linux';
} else if (userAgent.includes('mac')) {
detectedPlatform = 'macos';
}
setPlatform(detectedPlatform);
setSupportsWebPush(webPushSupport);
// Check current notification permission for supported platforms
if ('Notification' in window && webPushSupport) {
setNotificationPermission(Notification.permission);
}
// Load saved settings from Firebase
const loadSettings = async () => {
if (!user) return;
try {
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
const settingsSnap = await getDoc(settingsRef);
if (settingsSnap.exists()) {
const settings = settingsSnap.data();
setNotificationsEnabled(settings.enabled || false);
setReminderTime(settings.time || '09:00');
if (settings.enabled && webPushSupport) {
scheduleNotification();
}
}
} catch (error: any) {
if (error?.code !== 'unavailable') {
console.error('Error loading settings:', error);
}
}
};
loadSettings();
}, [user]);
const requestNotificationPermission = async () => {
if (!supportsWebPush || !user) return;
if ('Notification' in window) {
const permission = await Notification.requestPermission();
setNotificationPermission(permission);
if (permission === 'granted') {
setNotificationsEnabled(true);
// Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
await setDoc(settingsRef, {
enabled: true,
time: reminderTime,
lastReminderDate: new Date().toISOString()
}, { merge: true });
scheduleNotification();
}
}
};
const toggleNotifications = async () => {
if (!user) return;
if (supportsWebPush) {
if (!notificationsEnabled && notificationPermission !== 'granted') {
await requestNotificationPermission();
} else {
setNotificationsEnabled(!notificationsEnabled);
// Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
await setDoc(settingsRef, {
enabled: !notificationsEnabled,
time: reminderTime,
lastReminderDate: new Date().toISOString()
}, { merge: true });
if (!notificationsEnabled) {
scheduleNotification();
}
}
} else {
// For iOS and unsupported platforms, just enable in-app reminders
setNotificationsEnabled(!notificationsEnabled);
// Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
await setDoc(settingsRef, {
enabled: !notificationsEnabled,
time: reminderTime
}, { merge: true });
}
};
const testNotificationNow = async () => {
if (supportsWebPush && Notification.permission === 'granted') {
new Notification('Test Prayer Reminder', {
body: 'This is a test notification. Your reminders are working!',
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
tag: 'prayer-reminder-test',
});
}
};
const handleTimeChange = (newTime: string) => {
if (!user) return;
setReminderTime(newTime);
// Save to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
setDoc(settingsRef, {
enabled: notificationsEnabled,
time: newTime
}, { merge: true });
if (notificationsEnabled) {
scheduleNotification();
}
};
const scheduleNotification = () => {
if (!supportsWebPush || !notificationsEnabled || !user) return;
// Calculate time until next reminder
const [hours, minutes] = reminderTime.split(':').map(Number);
const now = new Date();
const scheduledTime = new Date();
scheduledTime.setHours(hours, minutes, 0, 0);
// If time has passed today, schedule for tomorrow
if (scheduledTime <= now) {
scheduledTime.setDate(scheduledTime.getDate() + 1);
}
const timeUntilNotification = scheduledTime.getTime() - now.getTime();
// Only schedule if we have a valid time difference
if (timeUntilNotification > 0) {
setTimeout(() => {
if ('Notification' in window && Notification.permission === 'granted' && notificationsEnabled && user) {
new Notification('Time to Pray', {
body: 'Take a moment to connect with God and pray.',
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
tag: 'prayer-reminder',
requireInteraction: false,
});
// Save reminder shown timestamp to Firebase
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
setDoc(settingsRef, {
lastReminderDate: new Date().toISOString()
}, { merge: true });
// Reschedule for next day
setTimeout(scheduleNotification, 24 * 60 * 60 * 1000);
}
}, timeUntilNotification);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4 pb-32 pt-24">
{/* Animated Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute top-40 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-40 left-20 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
<div className="max-w-md mx-auto">
{/* Header */}
<div className="text-center mb-8">
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-2">
<span className="inline-flex items-center gap-2 justify-center">
<Info className="w-7 h-7 text-blue-400" />
About
</span>
</h1>
<p className="text-slate-400">Haver - Your Prayer Companion</p>
</div>
{/* About Card */}
<div className="bg-slate-800 rounded-lg shadow-sm p-4 space-y-6">
{/* Haver Meaning Section */}
<div className="border-b border-slate-700 pb-6">
<h2 className="text-lg font-semibold text-white mb-4">Haver חבר</h2>
<div className="space-y-3 text-sm text-slate-300">
<p>
<span className="font-semibold text-blue-400">Haver</span> is a Hebrew word (חבר) meaning
<span className="italic"> "friend," "companion," or "associate."</span>
</p>
<p>
Pronounced: <span className="font-mono text-slate-400">ha-VER</span> (stress on the second syllable)
</p>
<p className="text-slate-400">
In Jewish culture, Haver represents the importance of building bonds and connections among peers.
It reflects shared experiences and support within a community.
</p>
<p className="text-slate-400">
This app is your spiritual companion—a friend on your prayer journey,
helping you connect with God and track your faith walk.
</p>
</div>
</div>
{/* Account Section */}
<div className="border-b border-slate-700 pb-6">
<h2 className="text-lg font-semibold text-white mb-4">Account</h2>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-slate-400">Email</p>
<p className="text-white">{userEmail || 'Not set'}</p>
</div>
<div>
<p className="text-sm font-medium text-slate-400 mb-2">Verification Status</p>
{user?.emailVerified ? (
<div className="flex items-center gap-2 text-green-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-sm">Verified</span>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-yellow-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="text-sm">Not verified</span>
</div>
{verificationMessage && (
<div className={`px-3 py-2 rounded-lg text-sm ${
verificationMessage.includes('sent')
? 'bg-green-900 border border-green-700 text-green-200'
: 'bg-red-900 border border-red-700 text-red-200'
}`}>
{verificationMessage}
</div>
)}
<button
onClick={async () => {
setSendingVerification(true);
setVerificationMessage('');
try {
await sendVerificationEmail();
setVerificationMessage('✓ Verification email sent! Check your inbox.');
} catch (err: unknown) {
setVerificationMessage(err instanceof Error ? err.message : 'Failed to send email');
}
setSendingVerification(false);
}}
disabled={sendingVerification}
className="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
{sendingVerification ? 'Sending...' : 'Resend Verification Email'}
</button>
</div>
)}
</div>
</div>
</div>
{/* Prayer Reminders Section */}
<div className="border-b border-slate-700 pb-6">
<h2 className="text-lg font-semibold text-white mb-4">Prayer Reminders</h2>
<div className="space-y-4">
{/* Platform Support Info */}
{platform === 'ios' && (
<div className="bg-blue-900 border border-blue-700 text-blue-200 px-3 py-2 rounded-lg text-xs">
<p className="font-medium mb-1 inline-flex items-center gap-1.5"><Smartphone className="w-4 h-4" /> iOS: In-App Reminders</p>
<p>iOS Safari doesn't support push notifications. Enable reminders to get a message when you open the app.</p>
</div>
)}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-300">Daily Reminders</p>
<p className="text-xs text-slate-400">
{supportsWebPush ? 'Push notifications each day' : 'Reminders when you open the app'}
</p>
</div>
<button
onClick={toggleNotifications}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
notificationsEnabled ? 'bg-blue-600' : 'bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
notificationsEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{notificationPermission === 'denied' && supportsWebPush && (
<div className="bg-red-900 border border-red-700 text-red-200 px-3 py-2 rounded-lg text-xs">
Notifications are blocked. Please enable them in your browser settings.
</div>
)}
{notificationsEnabled && (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">
Reminder Time
</label>
<input
type="time"
value={reminderTime}
onChange={(e) => handleTimeChange(e.target.value)}
className="w-full p-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-2">
You'll receive {supportsWebPush ? 'a notification' : 'a reminder'} at {reminderTime} each day
</p>
</div>
{/* Test Button for Web Push Platforms */}
{supportsWebPush && notificationPermission === 'granted' && (
<button
onClick={testNotificationNow}
className="w-full px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-200 text-sm font-medium rounded-lg transition-colors border border-slate-600"
>
<span className="inline-flex items-center gap-1.5"><BellRing className="w-4 h-4" /> Test Notification</span>
</button>
)}
</div>
)}
</div>
</div>
{/* App Info Section */}
<div className="border-b border-slate-700 pb-6">
<h2 className="text-lg font-semibold text-white mb-4">App Info</h2>
<div className="space-y-2 text-sm text-slate-400">
<p>Haver v{APP_VERSION}</p>
<p>A simple, private space for your daily prayers and reflections.</p>
</div>
</div>
{/* What's New Section */}
<div className="border-b border-slate-700 pb-6">
<h2 className="text-lg font-semibold text-white mb-4">What's New</h2>
<ul className="list-disc list-inside text-sm text-slate-300 space-y-1">
{WHATS_NEW_BRIEF.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
{/* Logout and Delete Section */}
<div className="space-y-3">
<button
onClick={() => setShowLogoutConfirm(true)}
className="w-full px-4 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
>
Logout
</button>
<button
onClick={() => {
setShowDeleteDialog(true);
setDeleteStep(0);
setDeleteConfirmText('');
setDeleteEmail('');
setDeleteError('');
}}
className="w-full px-4 py-3 bg-red-900 hover:bg-red-800 text-red-200 font-medium rounded-lg transition-colors border border-red-700"
>
Delete Account
</button>
</div>
</div>
{/* Logout Confirmation Modal */}
{showLogoutConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-800 rounded-xl p-6 max-w-sm w-full">
<h2 className="text-xl font-bold text-white mb-2">Logout?</h2>
<p className="text-slate-400 mb-6">Are you sure you want to logout?</p>
<div className="flex gap-3">
<button
onClick={() => setShowLogoutConfirm(false)}
className="flex-1 px-4 py-2 rounded-lg bg-slate-700 text-white font-medium hover:bg-slate-600 transition-colors"
>
Cancel
</button>
<button
onClick={() => logout()}
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700 transition-colors"
>
Logout
</button>
</div>
</div>
</div>
)}
{/* Delete Account Multi-Step Modal */}
{showDeleteDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-slate-800 rounded-xl p-6 max-w-sm w-full">
{deleteStep === 0 && (
<>
<h2 className="text-xl font-bold text-red-400 mb-2">⚠️ Delete Account?</h2>
<p className="text-slate-300 mb-2 font-semibold">This action cannot be undone.</p>
<p className="text-slate-400 mb-6 text-sm">All your prayers, reflections, and account data will be permanently deleted.</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteDialog(false)}
className="flex-1 px-4 py-2 rounded-lg bg-slate-700 text-white font-medium hover:bg-slate-600 transition-colors"
>
Cancel
</button>
<button
onClick={() => setDeleteStep(1)}
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700 transition-colors"
>
Continue
</button>
</div>
</>
)}
{deleteStep === 1 && (
<>
<h2 className="text-xl font-bold text-red-400 mb-2">Are you absolutely sure?</h2>
<p className="text-slate-400 mb-6">You will lose all your prayers and data. This is permanent.</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteStep(0)}
className="flex-1 px-4 py-2 rounded-lg bg-slate-700 text-white font-medium hover:bg-slate-600 transition-colors"
>
Cancel
</button>
<button
onClick={() => setDeleteStep(2)}
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700 transition-colors"
>
I'm Sure
</button>
</div>
</>
)}
{deleteStep === 2 && (
<>
<h2 className="text-xl font-bold text-red-400 mb-2">Type DELETE to confirm</h2>
<p className="text-slate-400 mb-4">Enter the word "DELETE" to proceed:</p>
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value.toUpperCase())}
placeholder="Type DELETE"
className="w-full p-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 mb-4 focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
<div className="flex gap-3">
<button
onClick={() => setDeleteStep(1)}
className="flex-1 px-4 py-2 rounded-lg bg-slate-700 text-white font-medium hover:bg-slate-600 transition-colors"
>
Back
</button>
<button
onClick={() => deleteConfirmText === 'DELETE' ? setDeleteStep(3) : setDeleteError('Please type DELETE exactly')}
disabled={deleteConfirmText !== 'DELETE'}
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700 disabled:bg-red-900 disabled:cursor-not-allowed transition-colors"
>
Continue
</button>
</div>
{deleteError && <p className="text-red-400 text-sm mt-2">{deleteError}</p>}
</>
)}
{deleteStep === 3 && (
<>
<h2 className="text-xl font-bold text-red-400 mb-2">Confirm your email</h2>
<p className="text-slate-400 mb-4">Enter your email to verify:</p>
<input
type="email"
value={deleteEmail}
onChange={(e) => setDeleteEmail(e.target.value)}
placeholder="Enter your email"
className="w-full p-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 mb-4 focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
<div className="flex gap-3">
<button
onClick={() => setDeleteStep(2)}
className="flex-1 px-4 py-2 rounded-lg bg-slate-700 text-white font-medium hover:bg-slate-600 transition-colors"
>
Back
</button>
<button
onClick={() => deleteEmail === userEmail ? setDeleteStep(4) : setDeleteError('Email does not match')}
disabled={!deleteEmail}
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700 disabled:bg-red-900 disabled:cursor-not-allowed transition-colors"
>
Continue
</button>
</div>
{deleteError && <p className="text-red-400 text-sm mt-2">{deleteError}</p>}
</>
)}
{deleteStep === 4 && (
<>
<h2 className="text-xl font-bold text-red-400 mb-2">Final confirmation</h2>
<p className="text-slate-400 mb-4">Check this box to confirm you understand your account will be permanently deleted:</p>
<div className="bg-slate-700 p-3 rounded-lg mb-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={deleteConfirmText === 'CONFIRM'}
onChange={(e) => setDeleteConfirmText(e.target.checked ? 'CONFIRM' : '')}
className="w-5 h-5 rounded border-slate-600 bg-slate-600 text-red-600 focus:ring-2 focus:ring-red-500 cursor-pointer"
/>
<span className="text-slate-300 text-sm">
I understand this is permanent and cannot be undone
</span>
</label>
</div>
<div className="flex gap-3">
<button
onClick={() => setDeleteStep(3)}
className="flex-1 px-4 py-2 rounded-lg bg-slate-700 text-white font-medium hover:bg-slate-600 transition-colors"
>
Back
</button>
<button
onClick={async () => {
setDeleteLoading(true);
setDeleteError('');
try {
await deleteAccount();
} catch (err: unknown) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete account');
setDeleteLoading(false);
}
}}
disabled={deleteConfirmText !== 'CONFIRM' || deleteLoading}
className="flex-1 px-4 py-2 rounded-lg bg-red-700 text-white font-bold hover:bg-red-800 disabled:bg-red-900 disabled:cursor-not-allowed transition-colors"
>
{deleteLoading ? 'Deleting...' : 'Delete Account'}
</button>
</div>
{deleteError && <p className="text-red-400 text-sm mt-2">{deleteError}</p>}
</>
)}
</div>
</div>
)}
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
'use client';
interface Prayer {
id: string;
userId: string;
date: string;
text: string;
prayFor?: string;
answered?: boolean;
answerDetails?: string;
prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
}
interface AnsweredPrayersProps {
prayers: Prayer[];
onEdit?: (prayer: Prayer) => void;
}
import { CheckCircle2, Sparkles } from 'lucide-react';
export default function AnsweredPrayers({ prayers, onEdit }: AnsweredPrayersProps) {
const answeredPrayers = prayers.filter(p => p.answered);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4 pb-32 pt-24">
{/* Animated Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute top-40 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-40 left-20 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
<div className="max-w-md mx-auto">
<div className="text-center mb-8">
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-2">
<span className="inline-flex items-center gap-2 justify-center">
<CheckCircle2 className="w-7 h-7 text-blue-400" />
Answered Prayers
</span>
</h1>
<p className="text-slate-400">Celebrate prayers God has answered</p>
</div>
{answeredPrayers.length > 0 ? (
<div className="space-y-4">
{answeredPrayers.map((prayer) => (
<div
key={prayer.id}
className="bg-slate-800/60 backdrop-blur-xl rounded-xl p-5 shadow-md border border-slate-700/50 border-l-4 border-green-500 hover:shadow-xl hover:bg-slate-800/80 transition-all cursor-pointer"
onClick={() => onEdit?.(prayer)}
>
<div className="flex items-start gap-3 mb-2">
<CheckCircle2 className="w-7 h-7 text-green-400" />
<div className="flex-1">
{prayer.prayFor && (
<div className="text-xs font-semibold text-green-400 mb-1 uppercase tracking-wide">
{prayer.prayFor}
</div>
)}
<p className="text-base text-slate-200 line-clamp-2 font-medium">{prayer.text}</p>
</div>
</div>
{prayer.answerDetails && (
<div className="mt-3 pt-3 border-t border-slate-700">
<p className="text-xs font-semibold text-slate-400 mb-1">How it was answered:</p>
<p className="text-sm text-slate-300 italic line-clamp-3">{prayer.answerDetails}</p>
</div>
)}
<div className="text-xs text-slate-500 mt-2">
{new Date(prayer.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Sparkles className="w-12 h-12 mb-4 text-blue-400 animate-pulse mx-auto" />
<p className="text-white font-semibold mb-2">No answered prayers yet</p>
<p className="text-sm text-slate-400">
Mark prayers as answered in the Compose tab to see them here
</p>
</div>
)}
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import React from 'react';
import { useAuth } from '@/context/AuthContext';
import { useState } from 'react';
import { Home, PenSquare, Calendar as CalendarIcon, HandHeart, CheckCircle2, BookOpen, Info, EllipsisVertical, LogOut } from 'lucide-react';
interface BottomNavProps {
activeTab: 'home' | 'compose' | 'journal' | 'answered' | 'praying' | 'reflections' | 'about';
onTabChange: (tab: 'home' | 'compose' | 'journal' | 'answered' | 'praying' | 'reflections' | 'about') => void;
}
export default function BottomNav({ activeTab, onTabChange }: BottomNavProps) {
const { logout, user } = useAuth();
const [showMoreMenu, setShowMoreMenu] = useState(false);
const primaryNavItems = [
{ id: 'home' as const, label: 'Home', icon: <Home className="w-5 h-5" /> },
{ id: 'compose' as const, label: 'Compose', icon: <PenSquare className="w-5 h-5" /> },
{ id: 'journal' as const, label: 'Journal', icon: <CalendarIcon className="w-5 h-5" /> },
{ id: 'praying' as const, label: 'Praying', icon: <HandHeart className="w-5 h-5" /> },
];
const moreMenuItems = [
{ id: 'answered' as const, label: 'Answered', icon: <CheckCircle2 className="w-5 h-5" /> },
{ id: 'reflections' as const, label: 'Reflections', icon: <BookOpen className="w-5 h-5" /> },
{ id: 'about' as const, label: 'About', icon: <Info className="w-5 h-5" /> },
];
const allNavItems = [...primaryNavItems, ...moreMenuItems];
const handleMoreClick = () => {
setShowMoreMenu(!showMoreMenu);
};
const handleMenuItemClick = (tab: typeof activeTab) => {
onTabChange(tab);
setShowMoreMenu(false);
};
const isMoreActive = moreMenuItems.some(item => item.id === activeTab);
return (
<>
{/* More Menu Modal */}
{showMoreMenu && (
<>
<div
className="fixed inset-0 bg-black bg-opacity-50 md:hidden z-40"
onClick={() => setShowMoreMenu(false)}
/>
<div className="fixed bottom-16 right-4 left-4 bg-white dark:bg-slate-800 rounded-lg shadow-xl md:hidden z-50 overflow-hidden" style={{ marginBottom: 'var(--safe-area-inset-bottom)' }}>
{moreMenuItems.map(item => (
<button
key={item.id}
onClick={() => handleMenuItemClick(item.id)}
className={`w-full px-6 py-4 flex items-center gap-4 transition-colors border-b border-slate-200 dark:border-slate-700 last:border-b-0 ${
activeTab === item.id
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
>
<span className="text-xl">{item.icon}</span>
<span className="text-base font-medium">{item.label}</span>
</button>
))}
<button
onClick={() => {
logout();
setShowMoreMenu(false);
}}
className="w-full px-6 py-4 flex items-center gap-4 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="text-base font-medium">Logout</span>
</button>
</div>
</>
)}
{/* Mobile Bottom Navigation */}
<nav className="fixed bottom-0 left-0 right-0 bg-slate-800/80 backdrop-blur-md border-t border-slate-700 md:hidden z-50 shadow-lg" style={{ paddingBottom: 'var(--safe-area-inset-bottom)' }}>
<div className="flex justify-around">
{primaryNavItems.map(item => (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className={`flex-1 py-2.5 px-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
activeTab === item.id
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400'
}`}
>
<span className="text-xl">{item.icon}</span>
<span className="text-xs font-medium">{item.label}</span>
</button>
))}
<button
onClick={handleMoreClick}
className={`flex-1 py-2.5 px-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
isMoreActive || showMoreMenu
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-600 dark:text-slate-400'
}`}
>
<EllipsisVertical className="w-5 h-5" />
<span className="text-xs font-medium">More</span>
</button>
</div>
</nav>
{/* Desktop Top Navigation */}
<nav className="hidden md:fixed md:top-0 md:left-0 md:right-0 md:bg-slate-800/80 md:backdrop-blur-md md:border-b md:border-slate-700 md:z-50 md:flex md:items-center md:justify-between md:px-6 md:py-3 md:shadow-sm">
<div className="flex items-center gap-8">
<div className="flex items-center gap-2">
<img src="/icon-192x192.png" alt="Haver icon" className="w-6 h-6 rounded" />
<h1 className="text-2xl font-bold text-white">Haver</h1>
</div>
<div className="flex gap-1">
{allNavItems.map(item => (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
activeTab === item.id
? 'bg-blue-600 text-white'
: 'text-slate-300 hover:bg-slate-700'
}`}
>
<span className="inline-flex items-center justify-center">{item.icon}</span>
{item.label}
</button>
))}
</div>
</div>
<button
onClick={logout}
className="px-4 py-2 rounded-lg font-medium text-slate-300 hover:bg-slate-700 transition-colors"
>
Logout
</button>
</nav>
</>
);
}
+211
View File
@@ -0,0 +1,211 @@
'use client';
import React, { useState, useMemo } from 'react';
import { Calendar as CalendarIcon } from 'lucide-react';
interface Prayer {
id: string;
date: string;
text: string;
prayFor?: string;
lastPrayedFor?: string;
prayedDates?: string[];
}
interface CalendarViewProps {
prayers: Prayer[];
onDayClick: (date: string) => void;
}
export default function CalendarView({ prayers, onDayClick }: CalendarViewProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const daysWithEntries = useMemo(() => {
return new Set(prayers.map(p => p.date));
}, [prayers]);
const daysWithPrayedFor = useMemo(() => {
const dateCounts = new Map<string, number>();
prayers.forEach(p => {
// Check both old lastPrayedFor and new prayedDates array
if (p.prayedDates && p.prayedDates.length > 0) {
p.prayedDates.forEach(date => {
dateCounts.set(date, (dateCounts.get(date) || 0) + 1);
});
} else if (p.lastPrayedFor) {
// Backward compatibility with old format
dateCounts.set(p.lastPrayedFor, (dateCounts.get(p.lastPrayedFor) || 0) + 1);
}
});
return dateCounts;
}, [prayers]);
const getDaysInMonth = (date: Date) => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
};
const getFirstDayOfMonth = (date: Date) => {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
};
const daysInMonth = getDaysInMonth(currentMonth);
const firstDay = getFirstDayOfMonth(currentMonth);
const prevMonth = () =>
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1));
const nextMonth = () =>
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1));
const days = [];
for (let i = 0; i < firstDay; i++) {
days.push(null);
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 pt-24 px-4 pb-8">
{/* Animated Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute top-40 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-40 left-20 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8"> <img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" /> <h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-2">
<span className="inline-flex items-center gap-2 justify-center">
<CalendarIcon className="w-7 h-7 text-blue-400" />
Prayer Journal
</span>
</h1>
<p className="text-slate-400">
Click on days with entries to view them
</p>
</div>
{/* Calendar */}
<div className="bg-slate-800/60 backdrop-blur-xl rounded-xl shadow-lg p-6 border border-slate-700/50">
{/* Month Navigation */}
<div className="flex justify-between items-center mb-6">
<button
onClick={prevMonth}
className="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white transition-colors"
>
← Previous
</button>
<h2 className="text-2xl font-bold text-white">
{currentMonth.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
</h2>
<button
onClick={nextMonth}
className="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white transition-colors"
>
Next →
</button>
</div>
{/* Day Headers */}
<div className="grid grid-cols-7 gap-2 mb-4">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="text-center font-semibold text-slate-400 py-2">
{day}
</div>
))}
</div>
{/* Calendar Days */}
<div className="grid grid-cols-7 gap-2">
{days.map((day, index) => {
const dateString =
day !== null
? `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
: null;
const hasEntry = dateString ? daysWithEntries.has(dateString) : false;
const hasPrayedFor = dateString ? daysWithPrayedFor.has(dateString) : false;
const prayerCount = dateString
? prayers.filter(p => p.date === dateString).length
: 0;
const prayedForCount = dateString
? daysWithPrayedFor.get(dateString) || 0
: 0;
// Determine color based on activity type
let bgColor = 'bg-slate-700 text-white hover:bg-slate-600';
if (hasEntry && hasPrayedFor) {
// Both activities - purple (combination)
bgColor = 'bg-purple-500 text-white hover:bg-purple-600 shadow-md';
} else if (hasEntry) {
// Only prayer entries - blue
bgColor = 'bg-blue-500 text-white hover:bg-blue-600 shadow-md';
} else if (hasPrayedFor) {
// Only prayed for - green
bgColor = 'bg-green-500 text-white hover:bg-green-600 shadow-md';
}
return (
<button
key={index}
onClick={() => dateString && onDayClick(dateString)}
disabled={!day}
className={`aspect-square rounded-lg font-medium transition-all ${
!day
? 'bg-transparent cursor-default'
: bgColor
}`}
>
<div className="h-full flex flex-col items-center justify-center">
{day && (
<>
<span className="text-lg">{day}</span>
{(hasEntry || hasPrayedFor) && (
<div className="text-xs mt-1 opacity-90 text-center">
{hasEntry && <div>{prayerCount} entry</div>}
{hasPrayedFor && <div>{prayedForCount} prayed</div>}
</div>
)}
</>
)}
</div>
</button>
);
})}
</div>
</div>
{/* Color Legend */}
<div className="mt-6 bg-slate-800/60 backdrop-blur-xl rounded-xl p-5 shadow-lg border border-slate-700/50">
<h3 className="text-sm font-semibold text-white mb-3">Legend</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-blue-500"></div>
<span className="text-slate-300">Prayer Entry</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div>
<span className="text-slate-300">Prayed For</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-purple-500"></div>
<span className="text-slate-300">Both</span>
</div>
</div>
</div>
{/* Stats */}
<div className="mt-8 grid grid-cols-2 gap-4">
<div className="bg-slate-800/60 backdrop-blur-xl rounded-xl p-5 shadow-lg border border-slate-700/50">
<p className="text-slate-400 text-sm font-medium">Total Entries</p>
<p className="text-3xl font-bold text-white">{prayers.length}</p>
</div>
<div className="bg-slate-800/60 backdrop-blur-xl rounded-xl p-5 shadow-lg border border-slate-700/50">
<p className="text-slate-400 text-sm font-medium">Days with Entries</p>
<p className="text-3xl font-bold text-white">
{daysWithEntries.size}
</p>
</div>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { useState, useEffect } from 'react';
export default function Clock() {
const [time, setTime] = useState<string>('');
useEffect(() => {
// Set initial time
setTime(new Date().toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true
}));
// Update time every second
const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true
}));
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="text-center">
<div className="text-6xl font-bold text-white font-mono tracking-tight">
{time || '--:--:--'}
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
'use client';
import React, { useState } from 'react';
import toast from 'react-hot-toast';
import { PenSquare, Calendar as CalendarIcon, HandHeart, CheckCircle2, Sparkles, Save, Loader2, ChevronDown } from 'lucide-react';
interface DailyEntryProps {
date: string;
onDateChange: (date: string) => void;
text: string;
onTextChange: (text: string) => void;
prayFor: string;
onPrayForChange: (prayFor: string) => void;
prayForOptions?: string[];
answered?: boolean;
onAnsweredChange?: (answered: boolean) => void;
answerDetails?: string;
onAnswerDetailsChange?: (details: string) => void;
onSubmit: (e: React.FormEvent) => void;
loading?: boolean;
isEditing?: boolean;
}
export default function DailyEntry({
date,
onDateChange,
text,
onTextChange,
prayFor,
onPrayForChange,
prayForOptions = [],
answered = false,
onAnsweredChange,
answerDetails = '',
onAnswerDetailsChange,
onSubmit,
loading = false,
isEditing = false,
}: DailyEntryProps) {
const today = new Date().toISOString().split('T')[0];
const isToday = date === today;
const wordCount = text.split(/\s+/).filter(w => w).length;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 pt-24 px-4 pb-8">
{/* Animated Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute top-40 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-40 left-20 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-2">
<span className="inline-flex items-center gap-2 justify-center">
<PenSquare className="w-7 h-7 text-blue-400" />
{isToday ? "Today's Prayer" : 'Prayer Entry'}
</span>
</h1>
<p className="text-slate-400 text-lg">
{new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
{/* Entry Form */}
<form onSubmit={onSubmit} className="space-y-6">
{/* Date Input */}
<div>
<label className="block text-sm font-semibold text-slate-300 mb-3 inline-flex items-center gap-1.5"><CalendarIcon className="w-4 h-4" /> Date</label>
<input
type="date"
value={date}
onChange={(e) => onDateChange(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-800/60 backdrop-blur-xl border border-slate-700/50 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
/>
</div>
{/* Pray For */}
<div>
<label className="block text-sm font-semibold text-slate-300 mb-3 inline-flex items-center gap-1.5"><HandHeart className="w-4 h-4" /> Praying For (Optional)</label>
<div className="relative">
<input
type="text"
value={prayFor}
onChange={(e) => onPrayForChange(e.target.value)}
placeholder="Person, situation, or concern..."
list="prayfor-options"
className="w-full px-4 py-3.5 pr-12 rounded-xl bg-slate-800/60 backdrop-blur-xl border border-slate-700/50 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
/>
{prayForOptions.length > 0 && (
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-6 h-6 text-slate-400 pointer-events-none" />
)}
</div>
{prayForOptions.length > 0 && (
<datalist id="prayfor-options">
{prayForOptions.map((opt) => (
<option key={opt} value={opt} />
))}
</datalist>
)}
</div>
{/* Prayer Text */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-semibold text-slate-300 inline-flex items-center gap-1.5"><PenSquare className="w-4 h-4" /> Your Prayer</label>
<span className="text-xs text-slate-500">{wordCount} words</span>
</div>
<textarea
value={text}
onChange={(e) => onTextChange(e.target.value)}
placeholder="Write your thoughts, feelings, and prayers here..."
rows={10}
required
className="w-full px-4 py-3 rounded-xl bg-slate-800/60 backdrop-blur-xl border border-slate-700/50 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all resize-none font-serif"
/>
</div>
{/* Answered Checkbox */}
<div className="bg-slate-800/40 backdrop-blur-xl rounded-xl p-4 border border-slate-700/50">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={answered}
onChange={(e) => onAnsweredChange?.(e.target.checked)}
className="w-5 h-5 rounded border-slate-600 bg-slate-700 text-green-500 focus:ring-green-500 cursor-pointer"
/>
<span className="font-semibold text-slate-300 inline-flex items-center gap-1.5"><CheckCircle2 className="w-4 h-4" /> Mark as answered</span>
</label>
</div>
{/* Answer Details */}
{answered && (
<div className="bg-green-900/20 backdrop-blur-xl rounded-xl p-4 border border-green-500/30 animate-in fade-in-50 duration-300">
<label htmlFor="answerDetails" className="block text-sm font-semibold text-green-400 mb-3 inline-flex items-center gap-1.5">
<Sparkles className="w-4 h-4" /> How was it answered?
</label>
<textarea
id="answerDetails"
value={answerDetails}
onChange={(e) => onAnswerDetailsChange?.(e.target.value)}
placeholder="Share how God answered this prayer..."
rows={4}
className="w-full px-4 py-3 rounded-xl bg-slate-800/60 backdrop-blur-xl border border-green-500/30 text-white placeholder-slate-500 focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all resize-none font-serif"
/>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-blue-600 via-blue-500 to-purple-600 hover:from-blue-700 hover:via-blue-600 hover:to-purple-700 disabled:from-slate-600 disabled:to-slate-600 text-white font-bold py-4 px-6 rounded-xl transition-all duration-200 disabled:cursor-not-allowed shadow-lg hover:shadow-2xl transform hover:scale-105 border border-blue-400/30 disabled:border-slate-400/30"
>
{loading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-5 h-5 animate-spin" /> Saving...
</span>
) : isEditing ? (
<span className="inline-flex items-center gap-2">
<PenSquare className="w-5 h-5" /> Update Prayer
</span>
) : (
<span className="inline-flex items-center gap-2">
<Save className="w-5 h-5" /> Save Prayer
</span>
)}
</button>
</form>
</div>
</div>
);
}
+393
View File
@@ -0,0 +1,393 @@
'use client';
import { useState, useEffect } from 'react';
import Clock from './Clock';
import { HandHeart, CheckCircle2, PenSquare, BookOpen, Sparkles, Flame, BarChart3, MessageSquare } from 'lucide-react';
const getLocalISODate = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
interface Prayer {
id: string;
userId: string;
date: string;
text: string;
prayFor?: string;
answered?: boolean;
answerDetails?: string;
prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
}
interface HomePageProps {
prayers: Prayer[];
onStartPraying: () => void;
onSelectPrayer: (prayer: Prayer) => void;
onGoToPraying: () => void;
onGoToAnswered: () => void;
onGoToJournal: () => void;
onGoToReflections: () => void;
userName?: string;
mostPrayedFor?: string;
}
export default function HomePage({ prayers, onStartPraying, onSelectPrayer, onGoToPraying, onGoToAnswered, onGoToJournal, onGoToReflections, userName, mostPrayedFor }: HomePageProps) {
const today = getLocalISODate();
const recentPrayers = prayers.slice(0, 5);
const totalPrayers = prayers.length;
const uniqueDays = new Set(prayers.map(p => p.date)).size;
const answeredPrayers = prayers.filter(p => p.answered).length;
const prayedTodayDetails = prayers
.map(prayer => {
const dates = prayer.prayedDates && prayer.prayedDates.length > 0
? prayer.prayedDates
: prayer.lastPrayedFor
? [prayer.lastPrayedFor]
: [];
const todayOccurrences = dates.filter(d => d === today).length;
if (todayOccurrences === 0) return null;
return {
id: prayer.id,
title: prayer.prayFor || prayer.text || 'Prayer',
count: todayOccurrences,
};
})
.filter(Boolean) as { id: string; title: string; count: number }[];
const todaysUniquePrayers = prayedTodayDetails.length;
const todaysTotalTaps = prayedTodayDetails.reduce((sum, item) => sum + item.count, 0);
const topTodayPrayers = [...prayedTodayDetails]
.sort((a, b) => b.count - a.count || a.title.localeCompare(b.title))
.slice(0, 3);
// Get greeting based on time of day
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
};
// Get first name from display name or email
const getFirstName = () => {
if (!userName) return 'friend';
// If it's an email, get the part before @
if (userName.includes('@')) {
return userName.split('@')[0];
}
// If it's a full name, get first name
return userName.split(' ')[0];
};
// Count active prayer requests (not answered)
const activePrayerCount = prayers.filter(p => !p.answered).length;
// Count how many active prayers were prayed for today
const prayedTodayCount = prayers.filter(p => {
if (p.answered) return false;
const dates = p.prayedDates && p.prayedDates.length > 0
? p.prayedDates
: p.lastPrayedFor
? [p.lastPrayedFor]
: [];
return dates.some(d => d === today);
}).length;
// Calculate actual streak - consecutive days with prayers
const calculateStreak = () => {
if (prayers.length === 0) return 0;
// Get all dates where user either created a prayer OR marked something as prayed for
const activityDates = new Set<string>();
prayers.forEach(p => {
activityDates.add(p.date); // Date prayer was created
// Add all prayed dates from the array
if (p.prayedDates && p.prayedDates.length > 0) {
p.prayedDates.forEach((date: string) => activityDates.add(date));
} else if (p.lastPrayedFor) {
// Backward compatibility
activityDates.add(p.lastPrayedFor);
}
});
const sortedDates = Array.from(activityDates).sort().reverse();
let streak = 0;
let checkDate = new Date(today);
for (let i = 0; i < 365; i++) {
const dateStr = checkDate.toISOString().split('T')[0];
if (sortedDates.includes(dateStr)) {
streak++;
} else if (i === 0) {
// If today has no activity, streak is broken
break;
} else {
break;
}
checkDate.setDate(checkDate.getDate() - 1);
}
return streak;
};
const streak = calculateStreak();
// Calculate more stats
const avgPrayerLength = totalPrayers > 0
? Math.round(prayers.reduce((sum, p) => sum + p.text.length, 0) / totalPrayers)
: 0;
const answeredPercentage = totalPrayers > 0
? Math.round((answeredPrayers / totalPrayers) * 100)
: 0;
// Recent answered prayers
const recentAnswered = prayers.filter(p => p.answered).slice(0, 3);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4 pb-32">
<div className="pt-24">
{/* Animated Background Elements */}
<div className="fixed inset-0 -z-10">
<div className="absolute top-20 right-10 w-72 h-72 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-20 left-10 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
{/* Header with Personalized Greeting */}
<div className="max-w-md mx-auto mb-8 text-center">
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-3">
{getGreeting()}, {getFirstName()}!
</h1>
{activePrayerCount > 0 ? (
<div className="text-lg text-slate-300">
<p>
You have <span className="font-bold text-blue-400">{activePrayerCount}</span> {activePrayerCount === 1 ? 'thing' : 'things'} that need prayer today.
</p>
{prayedTodayCount > 0 && (
<p className="mt-1 inline-flex items-center gap-2">
<HandHeart className="w-5 h-5 text-green-400" /> You've prayed for <span className="font-bold text-green-400">{prayedTodayCount}</span> of them!
</p>
)}
</div>
) : (
<p className="text-lg text-slate-300">
All caught up! Start a new prayer to keep your journey going.
</p>
)}
<p className="text-slate-400 mt-4 text-sm">
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</p>
{/* Clock - Below Date */}
<div className="mt-3 flex justify-center">
<div className="scale-75">
<Clock />
</div>
</div>
</div>
{/* Stats Grid */}
<div className="max-w-md mx-auto mb-12">
{/* Today's Prayers - Featured */}
<div className="mb-4 bg-gradient-to-br from-blue-600 to-blue-700 rounded-2xl p-6 shadow-xl border border-blue-500/20 backdrop-blur-lg">
<div className="flex items-start justify-between">
<div>
<div className="text-blue-200 text-sm font-semibold mb-1">Today's Prayers</div>
<div className="text-5xl font-bold text-white">{todaysUniquePrayers}</div>
<div className="text-sm text-blue-100/80">unique prayed items</div>
</div>
<div className="text-right">
<div className="text-xs text-blue-100/70">Total taps</div>
<div className="text-2xl font-semibold text-white">{todaysTotalTaps}</div>
</div>
</div>
{topTodayPrayers.length > 0 ? (
<div className="mt-4 space-y-2">
{topTodayPrayers.map(item => (
<div key={item.id} className="flex items-center justify-between bg-white/5 border border-white/10 rounded-lg px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<HandHeart className="w-4 h-4 text-blue-100" />
<div className="text-sm text-white truncate" title={item.title}>{item.title}</div>
</div>
<div className="text-xs text-blue-100/80 whitespace-nowrap">{item.count} {item.count === 1 ? 'tap' : 'taps'}</div>
</div>
))}
</div>
) : (
<div className="mt-4 text-sm text-blue-100/80 flex items-center gap-2">
<HandHeart className="w-4 h-4" /> Mark a prayer as prayed to see it here.
</div>
)}
</div>
{/* Things to Pray For - Active Requests */}
{(() => {
const pendingPrayers = prayers.filter(p => !p.answered);
if (pendingPrayers.length > 0) {
return (
<button
onClick={onGoToPraying}
className="w-full text-left mb-4 bg-amber-900/20 backdrop-blur-xl rounded-2xl p-6 border border-amber-500/30 hover:border-amber-400/50 transition-all focus:outline-none focus:ring-2 focus:ring-amber-300 focus:ring-offset-2 focus:ring-offset-slate-900"
>
<div className="flex items-start justify-between">
<div>
<div className="text-amber-200 text-sm font-semibold mb-1 flex items-center gap-2">
<HandHeart className="w-4 h-4" /> Things to Pray For
</div>
<div className="text-4xl font-bold text-amber-200">{pendingPrayers.length}</div>
<div className="text-sm text-amber-200/80">active prayer requests</div>
</div>
<div className="text-xs font-semibold text-amber-200/90 flex items-center gap-1">
<span>Tap to review</span>
<span>→</span>
</div>
</div>
</button>
);
}
return null;
})()}
{/* Main Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-8">
{/* Total Prayers */}
<button
onClick={onGoToReflections}
className="text-left bg-slate-800/60 backdrop-blur-xl rounded-xl p-4 border border-slate-700/50 hover:border-slate-600 transition-all w-full focus:outline-none focus:ring-2 focus:ring-blue-400/60 focus:ring-offset-2 focus:ring-offset-slate-900"
>
<div className="text-slate-400 text-xs font-semibold mb-2">Total Prayers</div>
<div className="text-3xl font-bold text-white">{totalPrayers}</div>
<div className="text-xs text-slate-500 mt-2">All time</div>
<div className="text-[11px] text-blue-300 mt-1 flex items-center gap-1">Go to Reflections →</div>
</button>
{/* Days Prayed */}
<button
onClick={onGoToJournal}
className="text-left bg-slate-800/60 backdrop-blur-xl rounded-xl p-4 border border-slate-700/50 hover:border-slate-600 transition-all w-full focus:outline-none focus:ring-2 focus:ring-blue-400/60 focus:ring-offset-2 focus:ring-offset-slate-900"
>
<div className="text-slate-400 text-xs font-semibold mb-2">Days Prayed</div>
<div className="text-3xl font-bold text-white">{uniqueDays}</div>
<div className="text-xs text-slate-500 mt-2">Consistent</div>
<div className="text-[11px] text-blue-300 mt-1 flex items-center gap-1">Open Journal →</div>
</button>
{/* Answered Prayers */}
<button
onClick={onGoToAnswered}
className="text-left bg-slate-800/60 backdrop-blur-xl rounded-xl p-4 border border-slate-700/50 hover:border-slate-600 transition-all w-full focus:outline-none focus:ring-2 focus:ring-blue-400/60 focus:ring-offset-2 focus:ring-offset-slate-900"
>
<div className="text-slate-400 text-xs font-semibold mb-2 inline-flex items-center gap-1.5"><CheckCircle2 className="w-4 h-4" /> Answered</div>
<div className="text-3xl font-bold text-green-400">{answeredPrayers}</div>
<div className="text-xs text-slate-500 mt-2">{answeredPercentage}% answered</div>
<div className="text-[11px] text-green-300 mt-1 flex items-center gap-1">See answers →</div>
</button>
{/* Streak */}
<div className="bg-slate-800/60 backdrop-blur-xl rounded-xl p-4 border border-slate-700/50 hover:border-slate-600 transition-all">
<div className="text-slate-400 text-xs font-semibold mb-2 inline-flex items-center gap-1.5"><Flame className="w-4 h-4" /> Streak</div>
<div className="text-3xl font-bold text-orange-400">{streak}</div>
<div className="text-xs text-slate-500 mt-2">consecutive days</div>
</div>
</div>
{/* Secondary Stats */}
<div className="grid grid-cols-2 gap-4 mb-8">
{/* Avg Prayer Length */}
<div className="bg-purple-900/20 backdrop-blur-xl rounded-xl p-4 border border-purple-500/30">
<div className="text-purple-300 text-xs font-semibold mb-2 inline-flex items-center gap-1.5"><BarChart3 className="w-4 h-4" /> Avg Length</div>
<div className="text-2xl font-bold text-purple-200">{avgPrayerLength}</div>
<div className="text-xs text-purple-400 mt-2">characters</div>
</div>
{/* Most Prayed For */}
<div className="bg-pink-900/20 backdrop-blur-xl rounded-xl p-4 border border-pink-500/30">
<div className="text-pink-300 text-xs font-semibold mb-2 inline-flex items-center gap-1.5"><HandHeart className="w-4 h-4" /> Most Prayed For</div>
{mostPrayedFor ? (
<>
<div className="text-sm font-bold text-pink-200 line-clamp-1">{mostPrayedFor[0]}</div>
<div className="text-xs text-pink-400 mt-2">{mostPrayedFor[1]} times</div>
</>
) : (
<>
<div className="text-sm font-bold text-pink-200">Start praying!</div>
<div className="text-xs text-pink-400 mt-2">Click "Mark Prayed" to build your prayer focus</div>
</>
)}
</div>
</div>
</div>
{/* CTA Button */}
<div className="max-w-md mx-auto mb-12">
<button
onClick={onStartPraying}
className="w-full bg-gradient-to-r from-blue-600 via-blue-500 to-purple-600 hover:from-blue-700 hover:via-blue-600 hover:to-purple-700 text-white font-bold py-4 px-6 rounded-xl transition-all shadow-lg hover:shadow-2xl transform hover:scale-105 border border-blue-400/30 flex items-center justify-center gap-2"
>
<PenSquare className="w-5 h-5" />
<span>Start New Prayer</span>
</button>
</div>
{/* Recent Answered Prayers */}
{recentAnswered.length > 0 && (
<div className="max-w-md mx-auto mb-12">
<h2 className="text-lg font-bold text-green-400 mb-4 flex items-center gap-2">
<Sparkles className="w-5 h-5" /> Recently Answered
</h2>
<div className="space-y-3">
{recentAnswered.map((prayer) => (
<div
key={prayer.id}
className="bg-green-900/20 backdrop-blur-xl rounded-xl p-4 border border-green-500/30 hover:border-green-400/50 transition-all"
>
<div className="flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-green-400" />
<div className="flex-1 min-w-0">
{prayer.prayFor && (
<div className="text-xs font-bold text-green-300 mb-1 uppercase tracking-wider inline-flex items-center gap-1.5">
<HandHeart className="w-3.5 h-3.5" /> {prayer.prayFor}
</div>
)}
<p className="text-sm text-slate-300 line-clamp-2">{prayer.text}</p>
{prayer.answerDetails && (
<p className="text-xs text-green-400 mt-2 italic inline-flex items-center gap-1.5">
<MessageSquare className="w-4 h-4" /> "{prayer.answerDetails.substring(0, 60)}..."
</p>
)}
<div className="text-xs text-slate-500 mt-2">
{new Date(prayer.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty State */}
{prayers.length === 0 && (
<div className="max-w-md mx-auto text-center py-12">
<Sparkles className="w-12 h-12 mb-4 text-blue-400 animate-pulse mx-auto" />
<p className="text-white font-semibold mb-2">Start Your Prayer Journey</p>
<p className="text-slate-400 mb-6">
Create your first prayer entry and begin tracking your spiritual journey.
</p>
</div>
)}
</div>
</div>
);
}
+414
View File
@@ -0,0 +1,414 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/context/AuthContext';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [isSignup, setIsSignup] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [forgotEmail, setForgotEmail] = useState('');
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotMessage, setForgotMessage] = useState('');
const [showVerificationBanner, setShowVerificationBanner] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
const [needsVerification, setNeedsVerification] = useState(false);
const [resendEmail, setResendEmail] = useState('');
const [resendPassword, setResendPassword] = useState('');
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false);
const { login, signup, sendPasswordReset, sendVerificationEmailToUser } = useAuth();
// Persist verification state across re-renders
useEffect(() => {
const savedEmail = sessionStorage.getItem('needsVerification');
if (savedEmail) {
setNeedsVerification(true);
setResendEmail(savedEmail);
setError('Please verify your email address before logging in. Check your inbox for the verification link.');
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
if (isSignup) {
if (!firstName.trim()) {
setError('Please enter your first name');
setLoading(false);
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
setLoading(false);
return;
}
try {
const newUser = await signup(email, password, firstName);
// Send verification email to the newly created user
if (newUser) {
try {
await sendVerificationEmailToUser(newUser);
// Sign them out immediately - they must verify before login
const { signOut } = await import('firebase/auth');
const { auth } = await import('@/lib/firebase');
await signOut(auth);
setVerificationSent(true);
setShowVerificationBanner(true);
} catch (err) {
console.error('Error sending verification email:', err);
}
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'An error occurred');
}
} else {
try {
await login(email, password);
// Store remember me timestamp if checked
if (rememberMe) {
localStorage.setItem('rememberMeTimestamp', Date.now().toString());
} else {
localStorage.removeItem('rememberMeTimestamp');
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'An error occurred';
const errorCode = err instanceof Error && 'code' in err ? (err as any).code : '';
console.log('Login error:', errorMessage, 'Code:', errorCode); // Debug log
setError(errorMessage);
}
}
setLoading(false);
};
const handleResendVerification = async () => {
if (!resendPassword) {
setShowPasswordPrompt(true);
return;
}
setLoading(true);
setError('');
try {
// Try to login temporarily to send verification
const { signInWithEmailAndPassword } = await import('firebase/auth');
const { auth } = await import('@/lib/firebase');
const userCred = await signInWithEmailAndPassword(auth, resendEmail, resendPassword);
await sendVerificationEmailToUser(userCred.user);
// Sign them back out
const { signOut } = await import('firebase/auth');
await signOut(auth);
setError('');
setNeedsVerification(false);
sessionStorage.removeItem('needsVerification');
setShowVerificationBanner(true);
setVerificationSent(true);
setResendPassword('');
setShowPasswordPrompt(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to resend verification email. Check your password.');
setResendPassword('');
}
setLoading(false);
};
const handleForgotPassword = async () => {
if (!forgotEmail) {
setForgotMessage('Please enter your email address');
return;
}
setForgotLoading(true);
setForgotMessage('');
try {
await sendPasswordReset(forgotEmail);
setForgotMessage('Password reset email sent! Check your inbox.');
} catch (err: unknown) {
setForgotMessage(err instanceof Error ? err.message : 'Failed to send reset email');
}
setForgotLoading(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-black text-white flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold mb-2">Haver</h1>
<p className="text-gray-400">Your prayer companion</p>
</div>
<form onSubmit={handleSubmit} className="bg-gray-800 p-8 rounded-xl shadow-2xl space-y-6">
<h2 className="text-2xl font-semibold text-center">
{isSignup ? 'Create Account' : 'Welcome Back'}
</h2>
{error && (
<div className="bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded-lg">
{error}
</div>
)}
{needsVerification && (
<div className="bg-yellow-900 border-2 border-yellow-600 text-yellow-100 px-5 py-4 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-6 h-6 text-yellow-400 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<div className="flex-1">
<p className="font-bold text-lg mb-2">⚠️ Email Not Verified</p>
<p className="mb-2">Your account exists, but <strong>you haven't verified your email yet</strong>.</p>
<p className="text-sm text-yellow-300 mb-3">Check your inbox for <strong>{resendEmail}</strong> and click the verification link. Don't forget to check your spam folder!</p>
<div className="bg-yellow-950 border border-yellow-700 rounded p-3 mb-3 text-sm">
<p className="font-semibold mb-1">📧 Can't find the email?</p>
<p className="text-yellow-300 text-xs">Click the button below to send a new verification email.</p>
</div>
{showPasswordPrompt && (
<div className="mb-3">
<label className="block text-sm font-medium text-yellow-300 mb-2">Enter your password to resend:</label>
<input
type="password"
value={resendPassword}
onChange={(e) => setResendPassword(e.target.value)}
placeholder="Your password"
className="w-full p-2 bg-yellow-950 border border-yellow-700 rounded-lg text-white placeholder-yellow-600 focus:ring-2 focus:ring-yellow-500 focus:border-transparent"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleResendVerification();
}
}}
/>
</div>
)}
<button
type="button"
onClick={handleResendVerification}
disabled={loading}
className="w-full px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-yellow-800 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? 'Sending...' : '📨 Resend Verification Email'}
</button>
</div>
</div>
</div>
)}
{isSignup && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">First Name</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(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="Enter your first name"
required={isSignup}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(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="Enter your password"
required
/>
</div>
{isSignup && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
<input
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>
)}
{!isSignup && (
<div className="space-y-2">
<div className="flex items-center">
<input
type="checkbox"
id="rememberMe"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-2 focus:ring-blue-500 cursor-pointer"
/>
<label htmlFor="rememberMe" className="ml-2 text-sm text-gray-300 cursor-pointer">
Remember me for 15 days
</label>
</div>
<div className="text-right">
<button
type="button"
onClick={() => {
setShowForgotPassword(true);
setForgotEmail(email);
setForgotMessage('');
}}
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
Forgot password?
</button>
</div>
</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>
{showVerificationBanner && (
<div className="bg-blue-900 border-2 border-blue-600 text-blue-100 px-5 py-4 rounded-lg">
<div className="flex items-start gap-3 mb-3">
<svg className="w-6 h-6 text-blue-400 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
<div className="flex-1">
<p className="font-bold text-lg mb-2">⚠️ Email Verification Required</p>
<p className="mb-2">Your account has been created, but <strong>you must verify your email before you can sign in</strong>.</p>
<p className="text-sm text-blue-300 mb-3">A verification email has been sent to <strong>{email || resendEmail}</strong>. Please check your inbox (and spam folder) and click the verification link.</p>
<div className="bg-blue-950 border border-blue-700 rounded p-3 mb-3 text-sm">
<p className="font-semibold mb-1">📧 Didn't receive the email?</p>
<p className="text-blue-300 text-xs">It may take a few minutes to arrive. Check your spam/junk folder.</p>
</div>
<button
type="button"
onClick={async () => {
setLoading(true);
try {
const { signInWithEmailAndPassword, signOut } = await import('firebase/auth');
const { auth } = await import('@/lib/firebase');
const userCred = await signInWithEmailAndPassword(auth, email, password);
await sendVerificationEmailToUser(userCred.user);
await signOut(auth);
setError('');
alert('✓ Verification email resent! Check your inbox.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to resend email');
}
setLoading(false);
}}
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? 'Resending...' : '📨 Resend Verification Email'}
</button>
</div>
</div>
</div>
)}
<div className="text-center">
<button
type="button"
onClick={() => {
setIsSignup(!isSignup);
setError('');
setConfirmPassword('');
setFirstName('');
}}
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>
{/* Forgot Password Modal */}
{showForgotPassword && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-xl p-6 max-w-sm w-full">
<h2 className="text-xl font-bold text-white mb-2">Reset Password</h2>
<p className="text-gray-400 mb-4">Enter your email and we'll send you a link to reset your password.</p>
{forgotMessage && (
<div className={`px-4 py-2 rounded-lg mb-4 text-sm ${
forgotMessage.includes('sent')
? 'bg-green-900 border border-green-700 text-green-200'
: 'bg-red-900 border border-red-700 text-red-200'
}`}>
{forgotMessage}
</div>
)}
<input
type="email"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
placeholder="Enter your email"
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 mb-4"
/>
<div className="flex gap-3">
<button
onClick={() => {
setShowForgotPassword(false);
setForgotEmail('');
setForgotMessage('');
}}
className="flex-1 px-4 py-2 rounded-lg bg-gray-700 text-white font-medium hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={handleForgotPassword}
disabled={forgotLoading || !forgotEmail}
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed transition-colors flex items-center justify-center"
>
{forgotLoading ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 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>
Sending...
</>
) : (
'Send Reset Link'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+251
View File
@@ -0,0 +1,251 @@
'use client';
import React, { useState, useMemo } from 'react';
import { BookOpen, Calendar as CalendarIcon, CheckCircle2, Hourglass, XCircle, HandHeart } from 'lucide-react';
interface Prayer {
id: string;
userId: string;
date: string;
text: string;
prayFor?: string;
answered?: boolean;
prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
}
interface PrayerListProps {
prayers: Prayer[];
onPrayerClick: (prayer: Prayer) => void;
onEdit: (prayer: Prayer) => void;
}
export default function PrayerList({ prayers, onPrayerClick, onEdit }: PrayerListProps) {
const [searchTerm, setSearchTerm] = useState('');
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('newest');
const [filterAnswered, setFilterAnswered] = useState<'all' | 'answered' | 'unanswered'>('all');
// Fuzzy search helper - finds if search term matches any part of text
const fuzzyMatch = (text: string, searchTerm: string): boolean => {
if (!searchTerm) return true;
const searchLower = searchTerm.toLowerCase().trim();
const textLower = text.toLowerCase();
// Direct substring match (most relevant)
if (textLower.includes(searchLower)) return true;
// Split search into words and check if all words are found
const words = searchLower.split(/\s+/);
return words.every(word => textLower.includes(word));
};
const filtered = useMemo(() => {
return prayers
.filter(p => {
// Search filter
const matchesSearch = fuzzyMatch(p.text, searchTerm) ||
fuzzyMatch(p.prayFor || '', searchTerm);
if (!matchesSearch) return false;
// Answered filter
if (filterAnswered === 'answered' && !p.answered) return false;
if (filterAnswered === 'unanswered' && p.answered) return false;
return true;
})
.sort((a, b) => {
const comparison = new Date(b.date).getTime() - new Date(a.date).getTime();
return sortOrder === 'newest' ? comparison : -comparison;
});
}, [prayers, searchTerm, sortOrder, filterAnswered]);
const grouped = useMemo(() => {
return filtered.reduce((acc, prayer) => {
if (!acc[prayer.date]) acc[prayer.date] = [];
acc[prayer.date].push(prayer);
return acc;
}, {} as Record<string, Prayer[]>);
}, [filtered]);
const resultCount = filtered.length;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 pt-24 px-4 pb-8">
{/* Animated Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute top-40 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-40 left-20 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-2">
<span className="inline-flex items-center gap-2 justify-center">
<BookOpen className="w-7 h-7 text-blue-400" />
Past Prayers
</span>
</h1>
<p className="text-slate-400">
{resultCount} {resultCount === 1 ? 'prayer' : 'prayers'} found
</p>
</div>
{/* Filters */}
<div className="space-y-4 mb-8">
<input
type="text"
placeholder="Search by prayer text or person..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-slate-800/60 backdrop-blur-xl border border-slate-700/50 text-white placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
/>
{/* Sort Options */}
<div className="flex gap-2">
<button
onClick={() => setSortOrder('newest')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
sortOrder === 'newest'
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
<span className="inline-flex items-center gap-1.5"><CalendarIcon className="w-4 h-4" /> Newest</span>
</button>
<button
onClick={() => setSortOrder('oldest')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
sortOrder === 'oldest'
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
<span className="inline-flex items-center gap-1.5"><CalendarIcon className="w-4 h-4" /> Oldest</span>
</button>
</div>
{/* Answer Status Filter */}
<div className="flex gap-2">
<button
onClick={() => setFilterAnswered('all')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors text-sm ${
filterAnswered === 'all'
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
All Prayers
</button>
<button
onClick={() => setFilterAnswered('answered')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors text-sm ${
filterAnswered === 'answered'
? 'bg-green-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
<span className="inline-flex items-center gap-1.5"><CheckCircle2 className="w-4 h-4" /> Answered</span>
</button>
<button
onClick={() => setFilterAnswered('unanswered')}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors text-sm ${
filterAnswered === 'unanswered'
? 'bg-orange-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
<span className="inline-flex items-center gap-1.5"><Hourglass className="w-4 h-4" /> Waiting</span>
</button>
</div>
</div>
{/* Prayers List */}
<div className="space-y-6">
{filtered.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-400 text-lg">
{searchTerm || filterAnswered !== 'all' ? 'No prayers match your filters.' : 'No prayers yet. Start praying!'}
</p>
{searchTerm && (
<button
onClick={() => {
setSearchTerm('');
setFilterAnswered('all');
}}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Clear Filters
</button>
)}
</div>
) : (
Object.keys(grouped)
.sort()
.reverse()
.map(date => (
<div key={date}>
<h2 className="text-sm font-semibold text-slate-400 mb-3 uppercase tracking-wider">
{new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
year: grouped[date].some(
p => new Date(p.date).getFullYear() !== new Date().getFullYear()
)
? 'numeric'
: undefined,
})}
</h2>
<div className="space-y-4">
{grouped[date].map(prayer => (
<div
key={prayer.id}
className="bg-slate-800/60 backdrop-blur-xl rounded-xl p-6 shadow-md hover:shadow-xl transition-all cursor-pointer border border-slate-700/50 hover:border-blue-500"
onClick={() => onPrayerClick(prayer)}
>
<div className="flex items-start gap-3">
{prayer.answered && (
<CheckCircle2 className="w-6 h-6 text-green-400" />
)}
<div className="flex-1 min-w-0">
{prayer.prayFor && (
<p className="text-sm font-medium text-blue-400 mb-2 inline-flex items-center gap-1.5">
<HandHeart className="w-4 h-4" /> {prayer.prayFor}
</p>
)}
<p className="text-slate-200 line-clamp-3">
{prayer.text}
</p>
</div>
</div>
<div className="mt-4 flex justify-between items-center">
<span className="text-xs text-slate-500">
{new Date(date).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
})}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onEdit(prayer);
}}
className="text-xs px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Edit
</button>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import React, { useState, useEffect } from 'react';
import { BellRing, X } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
import { db } from '@/lib/firebase';
import { doc, getDoc, setDoc } from 'firebase/firestore';
interface ReminderBannerProps {
userEmail?: string;
}
export default function ReminderBanner({ userEmail }: ReminderBannerProps) {
const [showBanner, setShowBanner] = useState(false);
const [reminderTime, setReminderTime] = useState('09:00');
const [isTime, setIsTime] = useState(false);
const { user } = useAuth();
useEffect(() => {
if (!user) return;
// Load settings from Firebase
const loadSettingsAndCheck = async () => {
try {
const settingsRef = doc(db, 'users', user.uid, 'settings', 'notifications');
const settingsSnap = await getDoc(settingsRef);
if (!settingsSnap.exists()) return;
const settings = settingsSnap.data();
const remindersEnabled = settings.enabled || false;
const savedTime = settings.time || '09:00';
const lastReminderDate = settings.lastReminderDate;
setReminderTime(savedTime);
if (!remindersEnabled) return;
// Check if we should show the banner
const checkIfTimeToRemind = () => {
const now = new Date();
const [hours, minutes] = savedTime.split(':').map(Number);
const reminderTodayTime = new Date();
reminderTodayTime.setHours(hours, minutes, 0, 0);
// Check if current time is within 5 minutes of reminder time
const timeDiff = Math.abs(now.getTime() - reminderTodayTime.getTime());
const withinWindow = timeDiff < 5 * 60 * 1000; // 5 minute window
if (withinWindow) {
// Check if we already showed reminder today
const today = new Date().toISOString().split('T')[0];
const lastDate = lastReminderDate?.split('T')[0];
if (lastDate !== today) {
setIsTime(true);
setShowBanner(true);
// Update last reminder date in Firebase
setDoc(settingsRef, {
lastReminderDate: new Date().toISOString()
}, { merge: true });
}
}
};
checkIfTimeToRemind();
// Check every minute
const interval = setInterval(checkIfTimeToRemind, 60000);
return () => clearInterval(interval);
} catch (error: any) {
if (error?.code !== 'unavailable') {
console.error('Error loading reminder settings:', error);
}
}
};
loadSettingsAndCheck();
}, [user]);
if (!showBanner) return null;
return (
<div className="fixed top-0 left-0 right-0 z-40 p-4">
<div className="max-w-md mx-auto bg-gradient-to-r from-blue-600 to-blue-500 rounded-lg shadow-lg p-4 animate-in slide-in-from-top">
<div className="flex items-start gap-3">
<BellRing className="w-6 h-6 text-white mt-1" />
<div className="flex-1">
<h3 className="font-semibold text-white">Time to Pray</h3>
<p className="text-blue-100 text-sm mt-1">
Take a moment to connect with God and pray.
</p>
</div>
<button
onClick={() => setShowBanner(false)}
className="text-blue-200 hover:text-white transition-colors mt-1"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="mt-3 flex gap-2">
<button
onClick={() => setShowBanner(false)}
className="flex-1 px-3 py-2 bg-blue-700 hover:bg-blue-800 text-white text-sm font-medium rounded transition-colors"
>
Dismiss
</button>
<button
onClick={() => {
setShowBanner(false);
window.location.href = '/?tab=compose';
}}
className="flex-1 px-3 py-2 bg-white hover:bg-blue-50 text-blue-600 text-sm font-medium rounded transition-colors"
>
Compose Prayer
</button>
</div>
</div>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
'use client';
import React from 'react';
import { HandHeart, Sparkles, CheckCircle2, Target } from 'lucide-react';
interface Prayer {
id: string;
userId: string;
date: string;
text: string;
prayFor?: string;
answered?: boolean;
answerDetails?: string;
prayFrequency?: number; // 1-7 times per week
lastPrayedFor?: string; // ISO date string
prayedDates?: string[]; // Array of dates when prayed for
}
interface ThingsToPrayForProps {
prayers: Prayer[];
onEdit: (prayer: Prayer) => void;
onMarkPrayed: (prayerId: string) => void;
}
export default function ThingsToPrayFor({ prayers, onEdit, onMarkPrayed }: ThingsToPrayForProps) {
// Filter for unanswered prayers
const thingsToPrayFor = prayers.filter(p => !p.answered).sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Group by prayer for (people/topics)
const groupedByTopic: Record<string, Prayer[]> = {};
thingsToPrayFor.forEach(prayer => {
const topic = prayer.prayFor || 'Other Concerns';
if (!groupedByTopic[topic]) {
groupedByTopic[topic] = [];
}
groupedByTopic[topic].push(prayer);
});
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 pt-24 px-4 pb-8">
{/* Animated Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute top-40 right-20 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div className="absolute bottom-40 left-20 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
</div>
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<img src="/icon-192x192.png" alt="Haver icon" className="w-12 h-12 mx-auto mb-3 rounded" />
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent mb-2">
<span className="inline-flex items-center gap-2 justify-center">
<HandHeart className="w-7 h-7 text-blue-400" />
Things to Pray For
</span>
</h1>
<p className="text-slate-400 text-lg">
{thingsToPrayFor.length} active {thingsToPrayFor.length === 1 ? 'prayer request' : 'prayer requests'}
</p>
</div>
{thingsToPrayFor.length === 0 ? (
<div className="text-center py-12">
<Sparkles className="w-12 h-12 mb-4 text-blue-400 animate-pulse mx-auto" />
<p className="text-white font-semibold mb-2">All Caught Up!</p>
<p className="text-slate-400">
You've marked all your prayers as answered. Praise God for His faithfulness!
</p>
</div>
) : (
<div className="space-y-8">
{Object.entries(groupedByTopic).map(([topic, prayersByTopic]) => (
<div key={topic}>
<div className="flex items-center gap-2 mb-4">
<div className="h-1 flex-1 bg-gradient-to-r from-blue-500/50 to-transparent rounded"></div>
<h2 className="text-xl font-bold text-blue-400 px-4 inline-flex items-center gap-2">
<Target className="w-5 h-5" /> {topic}
</h2>
<div className="h-1 flex-1 bg-gradient-to-l from-blue-500/50 to-transparent rounded"></div>
</div>
<div className="space-y-3">
{prayersByTopic.map((prayer, idx) => {
const daysAgo = Math.floor(
(new Date().getTime() - new Date(prayer.date).getTime()) / (1000 * 60 * 60 * 24)
);
const today = new Date().toISOString().split('T')[0];
const prayedToday = prayer.prayedDates?.includes(today) || prayer.lastPrayedFor === today;
return (
<div
key={prayer.id}
className="bg-slate-800/60 backdrop-blur-xl rounded-xl p-5 shadow-md border border-slate-700/50 transition-all hover:shadow-xl hover:bg-slate-800/80"
>
<div className="flex items-start gap-3">
<HandHeart className="w-7 h-7 text-blue-400 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-base text-slate-200 line-clamp-3 mb-3">
{prayer.text}
</p>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-slate-500">
{daysAgo === 0 ? 'Today' : daysAgo === 1 ? 'Yesterday' : `${daysAgo} days ago`}
</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onMarkPrayed(prayer.id);
}}
disabled={prayedToday}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
prayedToday
? 'bg-green-900/40 text-green-300 border border-green-500/30 cursor-not-allowed'
: 'bg-blue-900/40 text-blue-300 border border-blue-500/30 hover:bg-blue-900/60 hover:border-blue-400/50'
}`}
>
{prayedToday ? (
<span className="inline-flex items-center gap-1.5">
<CheckCircle2 className="w-4 h-4" /> Prayed Today
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<HandHeart className="w-4 h-4" /> Mark Prayed
</span>
)}
</button>
<button
onClick={() => onEdit(prayer)}
className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-slate-700/50 text-slate-300 border border-slate-600/50 hover:bg-slate-700/80 hover:border-slate-500/50 transition-all"
>
Edit
</button>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { User, signInWithEmailAndPassword, signOut, onAuthStateChanged, createUserWithEmailAndPassword, updateProfile, deleteUser, sendEmailVerification, sendPasswordResetEmail, reload } from 'firebase/auth';
import { auth, db, app } from '@/lib/firebase';
import { doc, setDoc, query, where, collection, getDocs, deleteDoc } from 'firebase/firestore';
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
signup: (email: string, password: string, firstName?: string) => Promise<User | null>;
logout: () => Promise<void>;
deleteAccount: () => Promise<void>;
sendVerificationEmail: () => Promise<void>;
sendVerificationEmailToUser: (user: User) => Promise<void>;
sendPasswordReset: (email: string) => Promise<void>;
loading: boolean;
isAdmin: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
let authResolved = false;
const unsubscribe = onAuthStateChanged(auth, (user) => {
if (mounted) {
// Check if "Remember Me" has expired (15 days)
if (user) {
const rememberMeTimestamp = localStorage.getItem('rememberMeTimestamp');
if (rememberMeTimestamp) {
const fifteenDaysInMs = 15 * 24 * 60 * 60 * 1000;
const now = Date.now();
const loginTime = parseInt(rememberMeTimestamp, 10);
if (now - loginTime > fifteenDaysInMs) {
// More than 15 days have passed, logout
localStorage.removeItem('rememberMeTimestamp');
signOut(auth);
setUser(null);
setLoading(false);
authResolved = true;
return;
}
}
}
// If we get null on first call, wait a bit for session restoration
if (!user && !authResolved) {
setTimeout(() => {
if (mounted && !authResolved) {
setUser(null);
setLoading(false);
authResolved = true;
}
}, 1000);
} else {
setUser(user);
setLoading(false);
authResolved = true;
}
}
});
// Safety timeout: force loading to false after 4 seconds
const timeout = setTimeout(() => {
if (mounted && !authResolved) {
setLoading(false);
authResolved = true;
}
}, 4000);
return () => {
mounted = false;
unsubscribe();
clearTimeout(timeout);
};
}, []);
// Configure admin email addresses here - only these users will have admin access
const adminEmails = ['nate@necloud.us', 'nmemmert@outlook.com'];
const isAdmin = user ? adminEmails.includes(user.email || '') : false;
const login = async (email: string, password: string) => {
await signInWithEmailAndPassword(auth, email, password);
};
const signup = async (email: string, password: string, firstName?: string) => {
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
if (firstName) {
await updateProfile(userCredential.user, { displayName: firstName });
}
try {
await sendEmailVerification(userCredential.user);
} catch (err) {
console.error('Failed to send verification email on signup:', err);
}
return userCredential.user;
};
const logout = async () => {
localStorage.removeItem('rememberMeTimestamp');
await signOut(auth);
};
const deleteAccount = async () => {
if (!user) throw new Error('No user logged in');
// Delete all prayers from Firestore
const prayersQuery = query(collection(db, 'prayers'), where('userId', '==', user.uid));
const prayersSnapshot = await getDocs(prayersQuery);
for (const prayerDoc of prayersSnapshot.docs) {
await deleteDoc(prayerDoc.ref);
}
// Delete user document if it exists
const userDocRef = doc(db, 'users', user.uid);
await deleteDoc(userDocRef).catch(() => {
// User doc might not exist, that's okay
});
// Delete Firebase Auth user
await deleteUser(user);
localStorage.removeItem('rememberMeTimestamp');
};
const sendVerificationEmail = async () => {
if (!user) throw new Error('No user logged in');
await sendEmailVerification(user);
};
const sendVerificationEmailToUser = async (userToVerify: User) => {
await sendEmailVerification(userToVerify);
};
const sendPasswordReset = async (email: string) => {
await sendPasswordResetEmail(auth, email);
};
return (
<AuthContext.Provider value={{ user, login, signup, logout, deleteAccount, sendVerificationEmail, sendVerificationEmailToUser, sendPasswordReset, loading, isAdmin }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
@@ -0,0 +1,62 @@
# Setup
If the user hasn't already installed the SDK, always run the user's node package manager of choice, and install the package in the directory ../package.json.
For more information on where the library is located, look at the connector.yaml file.
```ts
import { initializeApp } from 'firebase/app';
initializeApp({
// fill in your project config here using the values from your Firebase project or from the `firebase_get_sdk_config` tool from the Firebase MCP server.
});
```
Then, you can run the SDK as needed.
```ts
import { ... } from '@dataconnect/generated';
```
## React
### Setup
The user should make sure to install the `@tanstack/react-query` package, along with `@tanstack-query-firebase/react` and `firebase`.
Then, they should initialize Firebase:
```ts
import { initializeApp } from 'firebase/app';
initializeApp(firebaseConfig); /* your config here. To generate this, you can use the `firebase_sdk_config` MCP tool */
```
Then, they should add a `QueryClientProvider` to their root of their application.
Here's an example:
```ts
import { initializeApp } from 'firebase/app';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const firebaseConfig = {
/* your config here. To generate this, you can use the `firebase_sdk_config` MCP tool */
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
// Create a TanStack Query client instance
const queryClient = new QueryClient();
function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<MyApplication />
</QueryClientProvider>
)
}
render(<App />, document.getElementById('root'));
```
+104
View File
@@ -0,0 +1,104 @@
# Basic Usage
Always prioritize using a supported framework over using the generated SDK
directly. Supported frameworks simplify the developer experience and help ensure
best practices are followed.
### React
For each operation, there is a wrapper hook that can be used to call the operation.
Here are all of the hooks that get generated:
```ts
import { useCreateMovie, useUpsertUser, useAddReview, useDeleteReview, useListMovies, useListUsers, useListUserReviews, useGetMovieById, useSearchMovie } from '@dataconnect/generated/react';
// The types of these hooks are available in react/index.d.ts
const { data, isPending, isSuccess, isError, error } = useCreateMovie(createMovieVars);
const { data, isPending, isSuccess, isError, error } = useUpsertUser(upsertUserVars);
const { data, isPending, isSuccess, isError, error } = useAddReview(addReviewVars);
const { data, isPending, isSuccess, isError, error } = useDeleteReview(deleteReviewVars);
const { data, isPending, isSuccess, isError, error } = useListMovies();
const { data, isPending, isSuccess, isError, error } = useListUsers();
const { data, isPending, isSuccess, isError, error } = useListUserReviews();
const { data, isPending, isSuccess, isError, error } = useGetMovieById(getMovieByIdVars);
const { data, isPending, isSuccess, isError, error } = useSearchMovie(searchMovieVars);
```
Here's an example from a different generated SDK:
```ts
import { useListAllMovies } from '@dataconnect/generated/react';
function MyComponent() {
const { isLoading, data, error } = useListAllMovies();
if(isLoading) {
return <div>Loading...</div>
}
if(error) {
return <div> An Error Occurred: {error} </div>
}
}
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MyComponent from './my-component';
function App() {
const queryClient = new QueryClient();
return <QueryClientProvider client={queryClient}>
<MyComponent />
</QueryClientProvider>
}
```
## Advanced Usage
If a user is not using a supported framework, they can use the generated SDK directly.
Here's an example of how to use it with the first 5 operations:
```js
import { createMovie, upsertUser, addReview, deleteReview, listMovies, listUsers, listUserReviews, getMovieById, searchMovie } from '@dataconnect/generated';
// Operation CreateMovie: For variables, look at type CreateMovieVars in ../index.d.ts
const { data } = await CreateMovie(dataConnect, createMovieVars);
// Operation UpsertUser: For variables, look at type UpsertUserVars in ../index.d.ts
const { data } = await UpsertUser(dataConnect, upsertUserVars);
// Operation AddReview: For variables, look at type AddReviewVars in ../index.d.ts
const { data } = await AddReview(dataConnect, addReviewVars);
// Operation DeleteReview: For variables, look at type DeleteReviewVars in ../index.d.ts
const { data } = await DeleteReview(dataConnect, deleteReviewVars);
// Operation ListMovies:
const { data } = await ListMovies(dataConnect);
// Operation ListUsers:
const { data } = await ListUsers(dataConnect);
// Operation ListUserReviews:
const { data } = await ListUserReviews(dataConnect);
// Operation GetMovieById: For variables, look at type GetMovieByIdVars in ../index.d.ts
const { data } = await GetMovieById(dataConnect, getMovieByIdVars);
// Operation SearchMovie: For variables, look at type SearchMovieVars in ../index.d.ts
const { data } = await SearchMovie(dataConnect, searchMovieVars);
```
File diff suppressed because it is too large. Load diff
+107
View File
@@ -0,0 +1,107 @@
import { queryRef, executeQuery, mutationRef, executeMutation, validateArgs } from 'firebase/data-connect';
export const connectorConfig = {
connector: 'example',
service: 'prayer',
location: 'us-east4'
};
export const createMovieRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'CreateMovie', inputVars);
}
createMovieRef.operationName = 'CreateMovie';
export function createMovie(dcOrVars, vars) {
return executeMutation(createMovieRef(dcOrVars, vars));
}
export const upsertUserRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'UpsertUser', inputVars);
}
upsertUserRef.operationName = 'UpsertUser';
export function upsertUser(dcOrVars, vars) {
return executeMutation(upsertUserRef(dcOrVars, vars));
}
export const addReviewRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'AddReview', inputVars);
}
addReviewRef.operationName = 'AddReview';
export function addReview(dcOrVars, vars) {
return executeMutation(addReviewRef(dcOrVars, vars));
}
export const deleteReviewRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'DeleteReview', inputVars);
}
deleteReviewRef.operationName = 'DeleteReview';
export function deleteReview(dcOrVars, vars) {
return executeMutation(deleteReviewRef(dcOrVars, vars));
}
export const listMoviesRef = (dc) => {
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'ListMovies');
}
listMoviesRef.operationName = 'ListMovies';
export function listMovies(dc) {
return executeQuery(listMoviesRef(dc));
}
export const listUsersRef = (dc) => {
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'ListUsers');
}
listUsersRef.operationName = 'ListUsers';
export function listUsers(dc) {
return executeQuery(listUsersRef(dc));
}
export const listUserReviewsRef = (dc) => {
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'ListUserReviews');
}
listUserReviewsRef.operationName = 'ListUserReviews';
export function listUserReviews(dc) {
return executeQuery(listUserReviewsRef(dc));
}
export const getMovieByIdRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'GetMovieById', inputVars);
}
getMovieByIdRef.operationName = 'GetMovieById';
export function getMovieById(dcOrVars, vars) {
return executeQuery(getMovieByIdRef(dcOrVars, vars));
}
export const searchMovieRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'SearchMovie', inputVars);
}
searchMovieRef.operationName = 'SearchMovie';
export function searchMovie(dcOrVars, vars) {
return executeQuery(searchMovieRef(dcOrVars, vars));
}
@@ -0,0 +1 @@
{"type":"module"}
+116
View File
@@ -0,0 +1,116 @@
const { queryRef, executeQuery, mutationRef, executeMutation, validateArgs } = require('firebase/data-connect');
const connectorConfig = {
connector: 'example',
service: 'prayer',
location: 'us-east4'
};
exports.connectorConfig = connectorConfig;
const createMovieRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'CreateMovie', inputVars);
}
createMovieRef.operationName = 'CreateMovie';
exports.createMovieRef = createMovieRef;
exports.createMovie = function createMovie(dcOrVars, vars) {
return executeMutation(createMovieRef(dcOrVars, vars));
};
const upsertUserRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'UpsertUser', inputVars);
}
upsertUserRef.operationName = 'UpsertUser';
exports.upsertUserRef = upsertUserRef;
exports.upsertUser = function upsertUser(dcOrVars, vars) {
return executeMutation(upsertUserRef(dcOrVars, vars));
};
const addReviewRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'AddReview', inputVars);
}
addReviewRef.operationName = 'AddReview';
exports.addReviewRef = addReviewRef;
exports.addReview = function addReview(dcOrVars, vars) {
return executeMutation(addReviewRef(dcOrVars, vars));
};
const deleteReviewRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return mutationRef(dcInstance, 'DeleteReview', inputVars);
}
deleteReviewRef.operationName = 'DeleteReview';
exports.deleteReviewRef = deleteReviewRef;
exports.deleteReview = function deleteReview(dcOrVars, vars) {
return executeMutation(deleteReviewRef(dcOrVars, vars));
};
const listMoviesRef = (dc) => {
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'ListMovies');
}
listMoviesRef.operationName = 'ListMovies';
exports.listMoviesRef = listMoviesRef;
exports.listMovies = function listMovies(dc) {
return executeQuery(listMoviesRef(dc));
};
const listUsersRef = (dc) => {
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'ListUsers');
}
listUsersRef.operationName = 'ListUsers';
exports.listUsersRef = listUsersRef;
exports.listUsers = function listUsers(dc) {
return executeQuery(listUsersRef(dc));
};
const listUserReviewsRef = (dc) => {
const { dc: dcInstance} = validateArgs(connectorConfig, dc, undefined);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'ListUserReviews');
}
listUserReviewsRef.operationName = 'ListUserReviews';
exports.listUserReviewsRef = listUserReviewsRef;
exports.listUserReviews = function listUserReviews(dc) {
return executeQuery(listUserReviewsRef(dc));
};
const getMovieByIdRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars, true);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'GetMovieById', inputVars);
}
getMovieByIdRef.operationName = 'GetMovieById';
exports.getMovieByIdRef = getMovieByIdRef;
exports.getMovieById = function getMovieById(dcOrVars, vars) {
return executeQuery(getMovieByIdRef(dcOrVars, vars));
};
const searchMovieRef = (dcOrVars, vars) => {
const { dc: dcInstance, vars: inputVars} = validateArgs(connectorConfig, dcOrVars, vars);
dcInstance._useGeneratedSdk();
return queryRef(dcInstance, 'SearchMovie', inputVars);
}
searchMovieRef.operationName = 'SearchMovie';
exports.searchMovieRef = searchMovieRef;
exports.searchMovie = function searchMovie(dcOrVars, vars) {
return executeQuery(searchMovieRef(dcOrVars, vars));
};
+250
View File
@@ -0,0 +1,250 @@
import { ConnectorConfig, DataConnect, QueryRef, QueryPromise, MutationRef, MutationPromise } from 'firebase/data-connect';
export const connectorConfig: ConnectorConfig;
export type TimestampString = string;
export type UUIDString = string;
export type Int64String = string;
export type DateString = string;
export interface AddReviewData {
review_upsert: Review_Key;
}
export interface AddReviewVariables {
movieId: UUIDString;
rating: number;
reviewText: string;
}
export interface CreateMovieData {
movie_insert: Movie_Key;
}
export interface CreateMovieVariables {
title: string;
genre: string;
imageUrl: string;
}
export interface DeleteReviewData {
review_delete?: Review_Key | null;
}
export interface DeleteReviewVariables {
movieId: UUIDString;
}
export interface GetMovieByIdData {
movie?: {
id: UUIDString;
title: string;
imageUrl: string;
genre?: string | null;
metadata?: {
rating?: number | null;
releaseYear?: number | null;
description?: string | null;
};
reviews: ({
reviewText?: string | null;
reviewDate: DateString;
rating?: number | null;
user: {
id: string;
username: string;
} & User_Key;
})[];
} & Movie_Key;
}
export interface GetMovieByIdVariables {
id: UUIDString;
}
export interface ListMoviesData {
movies: ({
id: UUIDString;
title: string;
imageUrl: string;
genre?: string | null;
} & Movie_Key)[];
}
export interface ListUserReviewsData {
user?: {
id: string;
username: string;
reviews: ({
rating?: number | null;
reviewDate: DateString;
reviewText?: string | null;
movie: {
id: UUIDString;
title: string;
} & Movie_Key;
})[];
} & User_Key;
}
export interface ListUsersData {
users: ({
id: string;
username: string;
} & User_Key)[];
}
export interface MovieMetadata_Key {
id: UUIDString;
__typename?: 'MovieMetadata_Key';
}
export interface Movie_Key {
id: UUIDString;
__typename?: 'Movie_Key';
}
export interface Review_Key {
userId: string;
movieId: UUIDString;
__typename?: 'Review_Key';
}
export interface SearchMovieData {
movies: ({
id: UUIDString;
title: string;
genre?: string | null;
imageUrl: string;
} & Movie_Key)[];
}
export interface SearchMovieVariables {
titleInput?: string | null;
genre?: string | null;
}
export interface UpsertUserData {
user_upsert: User_Key;
}
export interface UpsertUserVariables {
username: string;
}
export interface User_Key {
id: string;
__typename?: 'User_Key';
}
interface CreateMovieRef {
/* Allow users to create refs without passing in DataConnect */
(vars: CreateMovieVariables): MutationRef<CreateMovieData, CreateMovieVariables>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect, vars: CreateMovieVariables): MutationRef<CreateMovieData, CreateMovieVariables>;
operationName: string;
}
export const createMovieRef: CreateMovieRef;
export function createMovie(vars: CreateMovieVariables): MutationPromise<CreateMovieData, CreateMovieVariables>;
export function createMovie(dc: DataConnect, vars: CreateMovieVariables): MutationPromise<CreateMovieData, CreateMovieVariables>;
interface UpsertUserRef {
/* Allow users to create refs without passing in DataConnect */
(vars: UpsertUserVariables): MutationRef<UpsertUserData, UpsertUserVariables>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect, vars: UpsertUserVariables): MutationRef<UpsertUserData, UpsertUserVariables>;
operationName: string;
}
export const upsertUserRef: UpsertUserRef;
export function upsertUser(vars: UpsertUserVariables): MutationPromise<UpsertUserData, UpsertUserVariables>;
export function upsertUser(dc: DataConnect, vars: UpsertUserVariables): MutationPromise<UpsertUserData, UpsertUserVariables>;
interface AddReviewRef {
/* Allow users to create refs without passing in DataConnect */
(vars: AddReviewVariables): MutationRef<AddReviewData, AddReviewVariables>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect, vars: AddReviewVariables): MutationRef<AddReviewData, AddReviewVariables>;
operationName: string;
}
export const addReviewRef: AddReviewRef;
export function addReview(vars: AddReviewVariables): MutationPromise<AddReviewData, AddReviewVariables>;
export function addReview(dc: DataConnect, vars: AddReviewVariables): MutationPromise<AddReviewData, AddReviewVariables>;
interface DeleteReviewRef {
/* Allow users to create refs without passing in DataConnect */
(vars: DeleteReviewVariables): MutationRef<DeleteReviewData, DeleteReviewVariables>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect, vars: DeleteReviewVariables): MutationRef<DeleteReviewData, DeleteReviewVariables>;
operationName: string;
}
export const deleteReviewRef: DeleteReviewRef;
export function deleteReview(vars: DeleteReviewVariables): MutationPromise<DeleteReviewData, DeleteReviewVariables>;
export function deleteReview(dc: DataConnect, vars: DeleteReviewVariables): MutationPromise<DeleteReviewData, DeleteReviewVariables>;
interface ListMoviesRef {
/* Allow users to create refs without passing in DataConnect */
(): QueryRef<ListMoviesData, undefined>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect): QueryRef<ListMoviesData, undefined>;
operationName: string;
}
export const listMoviesRef: ListMoviesRef;
export function listMovies(): QueryPromise<ListMoviesData, undefined>;
export function listMovies(dc: DataConnect): QueryPromise<ListMoviesData, undefined>;
interface ListUsersRef {
/* Allow users to create refs without passing in DataConnect */
(): QueryRef<ListUsersData, undefined>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect): QueryRef<ListUsersData, undefined>;
operationName: string;
}
export const listUsersRef: ListUsersRef;
export function listUsers(): QueryPromise<ListUsersData, undefined>;
export function listUsers(dc: DataConnect): QueryPromise<ListUsersData, undefined>;
interface ListUserReviewsRef {
/* Allow users to create refs without passing in DataConnect */
(): QueryRef<ListUserReviewsData, undefined>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect): QueryRef<ListUserReviewsData, undefined>;
operationName: string;
}
export const listUserReviewsRef: ListUserReviewsRef;
export function listUserReviews(): QueryPromise<ListUserReviewsData, undefined>;
export function listUserReviews(dc: DataConnect): QueryPromise<ListUserReviewsData, undefined>;
interface GetMovieByIdRef {
/* Allow users to create refs without passing in DataConnect */
(vars: GetMovieByIdVariables): QueryRef<GetMovieByIdData, GetMovieByIdVariables>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect, vars: GetMovieByIdVariables): QueryRef<GetMovieByIdData, GetMovieByIdVariables>;
operationName: string;
}
export const getMovieByIdRef: GetMovieByIdRef;
export function getMovieById(vars: GetMovieByIdVariables): QueryPromise<GetMovieByIdData, GetMovieByIdVariables>;
export function getMovieById(dc: DataConnect, vars: GetMovieByIdVariables): QueryPromise<GetMovieByIdData, GetMovieByIdVariables>;
interface SearchMovieRef {
/* Allow users to create refs without passing in DataConnect */
(vars?: SearchMovieVariables): QueryRef<SearchMovieData, SearchMovieVariables>;
/* Allow users to pass in custom DataConnect instances */
(dc: DataConnect, vars?: SearchMovieVariables): QueryRef<SearchMovieData, SearchMovieVariables>;
operationName: string;
}
export const searchMovieRef: SearchMovieRef;
export function searchMovie(vars?: SearchMovieVariables): QueryPromise<SearchMovieData, SearchMovieVariables>;
export function searchMovie(dc: DataConnect, vars?: SearchMovieVariables): QueryPromise<SearchMovieData, SearchMovieVariables>;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@dataconnect/generated",
"version": "1.0.0",
"author": "Firebase <firebase-support@google.com> (https://firebase.google.com/)",
"description": "Generated SDK For example",
"license": "Apache-2.0",
"engines": {
"node": " >=18.0"
},
"typings": "index.d.ts",
"module": "esm/index.esm.js",
"main": "index.cjs.js",
"browser": "esm/index.esm.js",
"exports": {
".": {
"types": "./index.d.ts",
"require": "./index.cjs.js",
"default": "./esm/index.esm.js"
},
"./react": {
"types": "./react/index.d.ts",
"require": "./react/index.cjs.js",
"import": "./react/esm/index.esm.js",
"default": "./react/esm/index.esm.js"
},
"./package.json": "./package.json"
},
"peerDependencies": {
"firebase": "^11.3.0 || ^12.0.0",
"@tanstack-query-firebase/react": "^2.0.0"
}
}
+952
View File
@@ -0,0 +1,952 @@
# Generated React README
This README will guide you through the process of using the generated React SDK package for the connector `example`. It will also provide examples on how to use your generated SDK to call your Data Connect queries and mutations.
**If you're looking for the `JavaScript README`, you can find it at [`dataconnect-generated/README.md`](../README.md)**
***NOTE:** This README is generated alongside the generated SDK. If you make changes to this file, they will be overwritten when the SDK is regenerated.*
You can use this generated SDK by importing from the package `@dataconnect/generated/react` as shown below. Both CommonJS and ESM imports are supported.
You can also follow the instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#react).
# Table of Contents
- [**Overview**](#generated-react-readme)
- [**TanStack Query Firebase & TanStack React Query**](#tanstack-query-firebase-tanstack-react-query)
- [*Package Installation*](#installing-tanstack-query-firebase-and-tanstack-react-query-packages)
- [*Configuring TanStack Query*](#configuring-tanstack-query)
- [**Accessing the connector**](#accessing-the-connector)
- [*Connecting to the local Emulator*](#connecting-to-the-local-emulator)
- [**Queries**](#queries)
- [*ListMovies*](#listmovies)
- [*ListUsers*](#listusers)
- [*ListUserReviews*](#listuserreviews)
- [*GetMovieById*](#getmoviebyid)
- [*SearchMovie*](#searchmovie)
- [**Mutations**](#mutations)
- [*CreateMovie*](#createmovie)
- [*UpsertUser*](#upsertuser)
- [*AddReview*](#addreview)
- [*DeleteReview*](#deletereview)
# TanStack Query Firebase & TanStack React Query
This SDK provides [React](https://react.dev/) hooks generated specific to your application, for the operations found in the connector `example`. These hooks are generated using [TanStack Query Firebase](https://react-query-firebase.invertase.dev/) by our partners at Invertase, a library built on top of [TanStack React Query v5](https://tanstack.com/query/v5/docs/framework/react/overview).
***You do not need to be familiar with Tanstack Query or Tanstack Query Firebase to use this SDK.*** However, you may find it useful to learn more about them, as they will empower you as a user of this Generated React SDK.
## Installing TanStack Query Firebase and TanStack React Query Packages
In order to use the React generated SDK, you must install the `TanStack React Query` and `TanStack Query Firebase` packages.
```bash
npm i --save @tanstack/react-query @tanstack-query-firebase/react
```
```bash
npm i --save firebase@latest # Note: React has a peer dependency on ^11.3.0
```
You can also follow the installation instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#tanstack-install), or the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react) and [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/installation).
## Configuring TanStack Query
In order to use the React generated SDK in your application, you must wrap your application's component tree in a `QueryClientProvider` component from TanStack React Query. None of your generated React SDK hooks will work without this provider.
```javascript
import { QueryClientProvider } from '@tanstack/react-query';
// Create a TanStack Query client instance
const queryClient = new QueryClient()
function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<MyApplication />
</QueryClientProvider>
)
}
```
To learn more about `QueryClientProvider`, see the [TanStack React Query documentation](https://tanstack.com/query/latest/docs/framework/react/quick-start) and the [TanStack Query Firebase documentation](https://invertase.docs.page/tanstack-query-firebase/react#usage).
# Accessing the connector
A connector is a collection of Queries and Mutations. One SDK is generated for each connector - this SDK is generated for the connector `example`.
You can find more information about connectors in the [Data Connect documentation](https://firebase.google.com/docs/data-connect#how-does).
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig } from '@dataconnect/generated';
const dataConnect = getDataConnect(connectorConfig);
```
## Connecting to the local Emulator
By default, the connector will connect to the production service.
To connect to the emulator, you can use the following code.
You can also follow the emulator instructions from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#emulator-react-angular).
```javascript
import { connectDataConnectEmulator, getDataConnect } from 'firebase/data-connect';
import { connectorConfig } from '@dataconnect/generated';
const dataConnect = getDataConnect(connectorConfig);
connectDataConnectEmulator(dataConnect, 'localhost', 9399);
```
After it's initialized, you can call your Data Connect [queries](#queries) and [mutations](#mutations) using the hooks provided from your generated React SDK.
# Queries
The React generated SDK provides Query hook functions that call and return [`useDataConnectQuery`](https://react-query-firebase.invertase.dev/react/data-connect/querying) hooks from TanStack Query Firebase.
Calling these hook functions will return a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and the most recent data returned by the Query, among other things. To learn more about these hooks and how to use them, see the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react/data-connect/querying).
TanStack React Query caches the results of your Queries, so using the same Query hook function in multiple places in your application allows the entire application to automatically see updates to that Query's data.
Query hooks execute their Queries automatically when called, and periodically refresh, unless you change the `queryOptions` for the Query. To learn how to stop a Query from automatically executing, including how to make a query "lazy", see the [TanStack React Query documentation](https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries).
To learn more about TanStack React Query's Queries, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/queries).
## Using Query Hooks
Here's a general overview of how to use the generated Query hooks in your code:
- If the Query has no variables, the Query hook function does not require arguments.
- If the Query has any required variables, the Query hook function will require at least one argument: an object that contains all the required variables for the Query.
- If the Query has some required and some optional variables, only required variables are necessary in the variables argument object, and optional variables may be provided as well.
- If all of the Query's variables are optional, the Query hook function does not require any arguments.
- Query hook functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
- Query hooks functions can be called with or without passing in an `options` argument of type `useDataConnectQueryOptions`. To learn more about the `options` argument, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/query-options).
- ***Special case:*** If the Query has all optional variables and you would like to provide an `options` argument to the Query hook function without providing any variables, you must pass `undefined` where you would normally pass the Query's variables, and then may provide the `options` argument.
Below are examples of how to use the `example` connector's generated Query hook functions to execute each Query. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#operations-react-angular).
## ListMovies
You can execute the `ListMovies` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
```javascript
useListMovies(dc: DataConnect, options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
```
You can also pass in a `DataConnect` instance to the Query hook function.
```javascript
useListMovies(options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
```
### Variables
The `ListMovies` Query has no variables.
### Return Type
Recall that calling the `ListMovies` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `ListMovies` Query is of type `ListMoviesData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface ListMoviesData {
movies: ({
id: UUIDString;
title: string;
imageUrl: string;
genre?: string | null;
} & Movie_Key)[];
}
```
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
### Using `ListMovies`'s Query hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig } from '@dataconnect/generated';
import { useListMovies } from '@dataconnect/generated/react'
export default function ListMoviesComponent() {
// You don't have to do anything to "execute" the Query.
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
const query = useListMovies();
// You can also pass in a `DataConnect` instance to the Query hook function.
const dataConnect = getDataConnect(connectorConfig);
const query = useListMovies(dataConnect);
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
const options = { staleTime: 5 * 1000 };
const query = useListMovies(options);
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = { staleTime: 5 * 1000 };
const query = useListMovies(dataConnect, options);
// Then, you can render your component dynamically based on the status of the Query.
if (query.isPending) {
return <div>Loading...</div>;
}
if (query.isError) {
return <div>Error: {query.error.message}</div>;
}
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
if (query.isSuccess) {
console.log(query.data.movies);
}
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
## ListUsers
You can execute the `ListUsers` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
```javascript
useListUsers(dc: DataConnect, options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
```
You can also pass in a `DataConnect` instance to the Query hook function.
```javascript
useListUsers(options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
```
### Variables
The `ListUsers` Query has no variables.
### Return Type
Recall that calling the `ListUsers` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `ListUsers` Query is of type `ListUsersData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface ListUsersData {
users: ({
id: string;
username: string;
} & User_Key)[];
}
```
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
### Using `ListUsers`'s Query hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig } from '@dataconnect/generated';
import { useListUsers } from '@dataconnect/generated/react'
export default function ListUsersComponent() {
// You don't have to do anything to "execute" the Query.
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
const query = useListUsers();
// You can also pass in a `DataConnect` instance to the Query hook function.
const dataConnect = getDataConnect(connectorConfig);
const query = useListUsers(dataConnect);
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
const options = { staleTime: 5 * 1000 };
const query = useListUsers(options);
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = { staleTime: 5 * 1000 };
const query = useListUsers(dataConnect, options);
// Then, you can render your component dynamically based on the status of the Query.
if (query.isPending) {
return <div>Loading...</div>;
}
if (query.isError) {
return <div>Error: {query.error.message}</div>;
}
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
if (query.isSuccess) {
console.log(query.data.users);
}
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
## ListUserReviews
You can execute the `ListUserReviews` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
```javascript
useListUserReviews(dc: DataConnect, options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
```
You can also pass in a `DataConnect` instance to the Query hook function.
```javascript
useListUserReviews(options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
```
### Variables
The `ListUserReviews` Query has no variables.
### Return Type
Recall that calling the `ListUserReviews` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `ListUserReviews` Query is of type `ListUserReviewsData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface ListUserReviewsData {
user?: {
id: string;
username: string;
reviews: ({
rating?: number | null;
reviewDate: DateString;
reviewText?: string | null;
movie: {
id: UUIDString;
title: string;
} & Movie_Key;
})[];
} & User_Key;
}
```
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
### Using `ListUserReviews`'s Query hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig } from '@dataconnect/generated';
import { useListUserReviews } from '@dataconnect/generated/react'
export default function ListUserReviewsComponent() {
// You don't have to do anything to "execute" the Query.
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
const query = useListUserReviews();
// You can also pass in a `DataConnect` instance to the Query hook function.
const dataConnect = getDataConnect(connectorConfig);
const query = useListUserReviews(dataConnect);
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
const options = { staleTime: 5 * 1000 };
const query = useListUserReviews(options);
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = { staleTime: 5 * 1000 };
const query = useListUserReviews(dataConnect, options);
// Then, you can render your component dynamically based on the status of the Query.
if (query.isPending) {
return <div>Loading...</div>;
}
if (query.isError) {
return <div>Error: {query.error.message}</div>;
}
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
if (query.isSuccess) {
console.log(query.data.user);
}
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
## GetMovieById
You can execute the `GetMovieById` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
```javascript
useGetMovieById(dc: DataConnect, vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
```
You can also pass in a `DataConnect` instance to the Query hook function.
```javascript
useGetMovieById(vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
```
### Variables
The `GetMovieById` Query requires an argument of type `GetMovieByIdVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface GetMovieByIdVariables {
id: UUIDString;
}
```
### Return Type
Recall that calling the `GetMovieById` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `GetMovieById` Query is of type `GetMovieByIdData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface GetMovieByIdData {
movie?: {
id: UUIDString;
title: string;
imageUrl: string;
genre?: string | null;
metadata?: {
rating?: number | null;
releaseYear?: number | null;
description?: string | null;
};
reviews: ({
reviewText?: string | null;
reviewDate: DateString;
rating?: number | null;
user: {
id: string;
username: string;
} & User_Key;
})[];
} & Movie_Key;
}
```
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
### Using `GetMovieById`'s Query hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig, GetMovieByIdVariables } from '@dataconnect/generated';
import { useGetMovieById } from '@dataconnect/generated/react'
export default function GetMovieByIdComponent() {
// The `useGetMovieById` Query hook requires an argument of type `GetMovieByIdVariables`:
const getMovieByIdVars: GetMovieByIdVariables = {
id: ...,
};
// You don't have to do anything to "execute" the Query.
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
const query = useGetMovieById(getMovieByIdVars);
// Variables can be defined inline as well.
const query = useGetMovieById({ id: ..., });
// You can also pass in a `DataConnect` instance to the Query hook function.
const dataConnect = getDataConnect(connectorConfig);
const query = useGetMovieById(dataConnect, getMovieByIdVars);
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
const options = { staleTime: 5 * 1000 };
const query = useGetMovieById(getMovieByIdVars, options);
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = { staleTime: 5 * 1000 };
const query = useGetMovieById(dataConnect, getMovieByIdVars, options);
// Then, you can render your component dynamically based on the status of the Query.
if (query.isPending) {
return <div>Loading...</div>;
}
if (query.isError) {
return <div>Error: {query.error.message}</div>;
}
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
if (query.isSuccess) {
console.log(query.data.movie);
}
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
## SearchMovie
You can execute the `SearchMovie` Query using the following Query hook function, which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts):
```javascript
useSearchMovie(dc: DataConnect, vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
```
You can also pass in a `DataConnect` instance to the Query hook function.
```javascript
useSearchMovie(vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
```
### Variables
The `SearchMovie` Query has an optional argument of type `SearchMovieVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface SearchMovieVariables {
titleInput?: string | null;
genre?: string | null;
}
```
### Return Type
Recall that calling the `SearchMovie` Query hook function returns a `UseQueryResult` object. This object holds the state of your Query, including whether the Query is loading, has completed, or has succeeded/failed, and any data returned by the Query, among other things.
To check the status of a Query, use the `UseQueryResult.status` field. You can also check for pending / success / error status using the `UseQueryResult.isPending`, `UseQueryResult.isSuccess`, and `UseQueryResult.isError` fields.
To access the data returned by a Query, use the `UseQueryResult.data` field. The data for the `SearchMovie` Query is of type `SearchMovieData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface SearchMovieData {
movies: ({
id: UUIDString;
title: string;
genre?: string | null;
imageUrl: string;
} & Movie_Key)[];
}
```
To learn more about the `UseQueryResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery).
### Using `SearchMovie`'s Query hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig, SearchMovieVariables } from '@dataconnect/generated';
import { useSearchMovie } from '@dataconnect/generated/react'
export default function SearchMovieComponent() {
// The `useSearchMovie` Query hook has an optional argument of type `SearchMovieVariables`:
const searchMovieVars: SearchMovieVariables = {
titleInput: ..., // optional
genre: ..., // optional
};
// You don't have to do anything to "execute" the Query.
// Call the Query hook function to get a `UseQueryResult` object which holds the state of your Query.
const query = useSearchMovie(searchMovieVars);
// Variables can be defined inline as well.
const query = useSearchMovie({ titleInput: ..., genre: ..., });
// Since all variables are optional for this Query, you can omit the `SearchMovieVariables` argument.
// (as long as you don't want to provide any `options`!)
const query = useSearchMovie();
// You can also pass in a `DataConnect` instance to the Query hook function.
const dataConnect = getDataConnect(connectorConfig);
const query = useSearchMovie(dataConnect, searchMovieVars);
// You can also pass in a `useDataConnectQueryOptions` object to the Query hook function.
const options = { staleTime: 5 * 1000 };
const query = useSearchMovie(searchMovieVars, options);
// If you'd like to provide options without providing any variables, you must
// pass `undefined` where you would normally pass the variables.
const query = useSearchMovie(undefined, options);
// You can also pass both a `DataConnect` instance and a `useDataConnectQueryOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = { staleTime: 5 * 1000 };
const query = useSearchMovie(dataConnect, searchMovieVars /** or undefined */, options);
// Then, you can render your component dynamically based on the status of the Query.
if (query.isPending) {
return <div>Loading...</div>;
}
if (query.isError) {
return <div>Error: {query.error.message}</div>;
}
// If the Query is successful, you can access the data returned using the `UseQueryResult.data` field.
if (query.isSuccess) {
console.log(query.data.movies);
}
return <div>Query execution {query.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
# Mutations
The React generated SDK provides Mutations hook functions that call and return [`useDataConnectMutation`](https://react-query-firebase.invertase.dev/react/data-connect/mutations) hooks from TanStack Query Firebase.
Calling these hook functions will return a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, and the most recent data returned by the Mutation, among other things. To learn more about these hooks and how to use them, see the [TanStack Query Firebase documentation](https://react-query-firebase.invertase.dev/react/data-connect/mutations).
Mutation hooks do not execute their Mutations automatically when called. Rather, after calling the Mutation hook function and getting a `UseMutationResult` object, you must call the `UseMutationResult.mutate()` function to execute the Mutation.
To learn more about TanStack React Query's Mutations, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/mutations).
## Using Mutation Hooks
Here's a general overview of how to use the generated Mutation hooks in your code:
- Mutation hook functions are not called with the arguments to the Mutation. Instead, arguments are passed to `UseMutationResult.mutate()`.
- If the Mutation has no variables, the `mutate()` function does not require arguments.
- If the Mutation has any required variables, the `mutate()` function will require at least one argument: an object that contains all the required variables for the Mutation.
- If the Mutation has some required and some optional variables, only required variables are necessary in the variables argument object, and optional variables may be provided as well.
- If all of the Mutation's variables are optional, the Mutation hook function does not require any arguments.
- Mutation hook functions can be called with or without passing in a `DataConnect` instance as an argument. If no `DataConnect` argument is passed in, then the generated SDK will call `getDataConnect(connectorConfig)` behind the scenes for you.
- Mutation hooks also accept an `options` argument of type `useDataConnectMutationOptions`. To learn more about the `options` argument, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/guides/mutations#mutation-side-effects).
- `UseMutationResult.mutate()` also accepts an `options` argument of type `useDataConnectMutationOptions`.
- ***Special case:*** If the Mutation has no arguments (or all optional arguments and you wish to provide none), and you want to pass `options` to `UseMutationResult.mutate()`, you must pass `undefined` where you would normally pass the Mutation's arguments, and then may provide the options argument.
Below are examples of how to use the `example` connector's generated Mutation hook functions to execute each Mutation. You can also follow the examples from the [Data Connect documentation](https://firebase.google.com/docs/data-connect/web-sdk#operations-react-angular).
## CreateMovie
You can execute the `CreateMovie` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
```javascript
useCreateMovie(options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
```
You can also pass in a `DataConnect` instance to the Mutation hook function.
```javascript
useCreateMovie(dc: DataConnect, options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
```
### Variables
The `CreateMovie` Mutation requires an argument of type `CreateMovieVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface CreateMovieVariables {
title: string;
genre: string;
imageUrl: string;
}
```
### Return Type
Recall that calling the `CreateMovie` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `CreateMovie` Mutation is of type `CreateMovieData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface CreateMovieData {
movie_insert: Movie_Key;
}
```
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
### Using `CreateMovie`'s Mutation hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig, CreateMovieVariables } from '@dataconnect/generated';
import { useCreateMovie } from '@dataconnect/generated/react'
export default function CreateMovieComponent() {
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
const mutation = useCreateMovie();
// You can also pass in a `DataConnect` instance to the Mutation hook function.
const dataConnect = getDataConnect(connectorConfig);
const mutation = useCreateMovie(dataConnect);
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useCreateMovie(options);
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useCreateMovie(dataConnect, options);
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
// The `useCreateMovie` Mutation requires an argument of type `CreateMovieVariables`:
const createMovieVars: CreateMovieVariables = {
title: ...,
genre: ...,
imageUrl: ...,
};
mutation.mutate(createMovieVars);
// Variables can be defined inline as well.
mutation.mutate({ title: ..., genre: ..., imageUrl: ..., });
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
mutation.mutate(createMovieVars, options);
// Then, you can render your component dynamically based on the status of the Mutation.
if (mutation.isPending) {
return <div>Loading...</div>;
}
if (mutation.isError) {
return <div>Error: {mutation.error.message}</div>;
}
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
if (mutation.isSuccess) {
console.log(mutation.data.movie_insert);
}
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
## UpsertUser
You can execute the `UpsertUser` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
```javascript
useUpsertUser(options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
```
You can also pass in a `DataConnect` instance to the Mutation hook function.
```javascript
useUpsertUser(dc: DataConnect, options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
```
### Variables
The `UpsertUser` Mutation requires an argument of type `UpsertUserVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface UpsertUserVariables {
username: string;
}
```
### Return Type
Recall that calling the `UpsertUser` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `UpsertUser` Mutation is of type `UpsertUserData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface UpsertUserData {
user_upsert: User_Key;
}
```
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
### Using `UpsertUser`'s Mutation hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig, UpsertUserVariables } from '@dataconnect/generated';
import { useUpsertUser } from '@dataconnect/generated/react'
export default function UpsertUserComponent() {
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
const mutation = useUpsertUser();
// You can also pass in a `DataConnect` instance to the Mutation hook function.
const dataConnect = getDataConnect(connectorConfig);
const mutation = useUpsertUser(dataConnect);
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useUpsertUser(options);
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useUpsertUser(dataConnect, options);
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
// The `useUpsertUser` Mutation requires an argument of type `UpsertUserVariables`:
const upsertUserVars: UpsertUserVariables = {
username: ...,
};
mutation.mutate(upsertUserVars);
// Variables can be defined inline as well.
mutation.mutate({ username: ..., });
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
mutation.mutate(upsertUserVars, options);
// Then, you can render your component dynamically based on the status of the Mutation.
if (mutation.isPending) {
return <div>Loading...</div>;
}
if (mutation.isError) {
return <div>Error: {mutation.error.message}</div>;
}
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
if (mutation.isSuccess) {
console.log(mutation.data.user_upsert);
}
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
## AddReview
You can execute the `AddReview` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
```javascript
useAddReview(options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
```
You can also pass in a `DataConnect` instance to the Mutation hook function.
```javascript
useAddReview(dc: DataConnect, options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
```
### Variables
The `AddReview` Mutation requires an argument of type `AddReviewVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface AddReviewVariables {
movieId: UUIDString;
rating: number;
reviewText: string;
}
```
### Return Type
Recall that calling the `AddReview` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `AddReview` Mutation is of type `AddReviewData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface AddReviewData {
review_upsert: Review_Key;
}
```
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
### Using `AddReview`'s Mutation hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig, AddReviewVariables } from '@dataconnect/generated';
import { useAddReview } from '@dataconnect/generated/react'
export default function AddReviewComponent() {
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
const mutation = useAddReview();
// You can also pass in a `DataConnect` instance to the Mutation hook function.
const dataConnect = getDataConnect(connectorConfig);
const mutation = useAddReview(dataConnect);
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useAddReview(options);
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useAddReview(dataConnect, options);
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
// The `useAddReview` Mutation requires an argument of type `AddReviewVariables`:
const addReviewVars: AddReviewVariables = {
movieId: ...,
rating: ...,
reviewText: ...,
};
mutation.mutate(addReviewVars);
// Variables can be defined inline as well.
mutation.mutate({ movieId: ..., rating: ..., reviewText: ..., });
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
mutation.mutate(addReviewVars, options);
// Then, you can render your component dynamically based on the status of the Mutation.
if (mutation.isPending) {
return <div>Loading...</div>;
}
if (mutation.isError) {
return <div>Error: {mutation.error.message}</div>;
}
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
if (mutation.isSuccess) {
console.log(mutation.data.review_upsert);
}
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
## DeleteReview
You can execute the `DeleteReview` Mutation using the `UseMutationResult` object returned by the following Mutation hook function (which is defined in [dataconnect-generated/react/index.d.ts](./index.d.ts)):
```javascript
useDeleteReview(options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
```
You can also pass in a `DataConnect` instance to the Mutation hook function.
```javascript
useDeleteReview(dc: DataConnect, options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
```
### Variables
The `DeleteReview` Mutation requires an argument of type `DeleteReviewVariables`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface DeleteReviewVariables {
movieId: UUIDString;
}
```
### Return Type
Recall that calling the `DeleteReview` Mutation hook function returns a `UseMutationResult` object. This object holds the state of your Mutation, including whether the Mutation is loading, has completed, or has succeeded/failed, among other things.
To check the status of a Mutation, use the `UseMutationResult.status` field. You can also check for pending / success / error status using the `UseMutationResult.isPending`, `UseMutationResult.isSuccess`, and `UseMutationResult.isError` fields.
To execute the Mutation, call `UseMutationResult.mutate()`. This function executes the Mutation, but does not return the data from the Mutation.
To access the data returned by a Mutation, use the `UseMutationResult.data` field. The data for the `DeleteReview` Mutation is of type `DeleteReviewData`, which is defined in [dataconnect-generated/index.d.ts](../index.d.ts). It has the following fields:
```javascript
export interface DeleteReviewData {
review_delete?: Review_Key | null;
}
```
To learn more about the `UseMutationResult` object, see the [TanStack React Query documentation](https://tanstack.com/query/v5/docs/framework/react/reference/useMutation).
### Using `DeleteReview`'s Mutation hook function
```javascript
import { getDataConnect } from 'firebase/data-connect';
import { connectorConfig, DeleteReviewVariables } from '@dataconnect/generated';
import { useDeleteReview } from '@dataconnect/generated/react'
export default function DeleteReviewComponent() {
// Call the Mutation hook function to get a `UseMutationResult` object which holds the state of your Mutation.
const mutation = useDeleteReview();
// You can also pass in a `DataConnect` instance to the Mutation hook function.
const dataConnect = getDataConnect(connectorConfig);
const mutation = useDeleteReview(dataConnect);
// You can also pass in a `useDataConnectMutationOptions` object to the Mutation hook function.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useDeleteReview(options);
// You can also pass both a `DataConnect` instance and a `useDataConnectMutationOptions` object.
const dataConnect = getDataConnect(connectorConfig);
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
const mutation = useDeleteReview(dataConnect, options);
// After calling the Mutation hook function, you must call `UseMutationResult.mutate()` to execute the Mutation.
// The `useDeleteReview` Mutation requires an argument of type `DeleteReviewVariables`:
const deleteReviewVars: DeleteReviewVariables = {
movieId: ...,
};
mutation.mutate(deleteReviewVars);
// Variables can be defined inline as well.
mutation.mutate({ movieId: ..., });
// You can also pass in a `useDataConnectMutationOptions` object to `UseMutationResult.mutate()`.
const options = {
onSuccess: () => { console.log('Mutation succeeded!'); }
};
mutation.mutate(deleteReviewVars, options);
// Then, you can render your component dynamically based on the status of the Mutation.
if (mutation.isPending) {
return <div>Loading...</div>;
}
if (mutation.isError) {
return <div>Error: {mutation.error.message}</div>;
}
// If the Mutation is successful, you can access the data returned using the `UseMutationResult.data` field.
if (mutation.isSuccess) {
console.log(mutation.data.review_delete);
}
return <div>Mutation execution {mutation.isSuccess ? 'successful' : 'failed'}!</div>;
}
```
@@ -0,0 +1,66 @@
import { createMovieRef, upsertUserRef, addReviewRef, deleteReviewRef, listMoviesRef, listUsersRef, listUserReviewsRef, getMovieByIdRef, searchMovieRef, connectorConfig } from '../../esm/index.esm.js';
import { validateArgs, CallerSdkTypeEnum } from 'firebase/data-connect';
import { useDataConnectQuery, useDataConnectMutation, validateReactArgs } from '@tanstack-query-firebase/react/data-connect';
export function useCreateMovie(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return createMovieRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useUpsertUser(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return upsertUserRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useAddReview(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return addReviewRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useDeleteReview(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return deleteReviewRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useListMovies(dcOrOptions, options) {
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
const ref = listMoviesRef(dcInstance);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useListUsers(dcOrOptions, options) {
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
const ref = listUsersRef(dcInstance);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useListUserReviews(dcOrOptions, options) {
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
const ref = listUserReviewsRef(dcInstance);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useGetMovieById(dcOrVars, varsOrOptions, options) {
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, true);
const ref = getMovieByIdRef(dcInstance, inputVars);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
export function useSearchMovie(dcOrVars, varsOrOptions, options) {
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, false);
const ref = searchMovieRef(dcInstance, inputVars);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
@@ -0,0 +1 @@
{"type":"module"}
@@ -0,0 +1,66 @@
const { createMovieRef, upsertUserRef, addReviewRef, deleteReviewRef, listMoviesRef, listUsersRef, listUserReviewsRef, getMovieByIdRef, searchMovieRef, connectorConfig } = require('../index.cjs.js');
const { validateArgs, CallerSdkTypeEnum } = require('firebase/data-connect');
const { useDataConnectQuery, useDataConnectMutation, validateReactArgs } = require('@tanstack-query-firebase/react/data-connect');
exports.useCreateMovie = function useCreateMovie(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return createMovieRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useUpsertUser = function useUpsertUser(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return upsertUserRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useAddReview = function useAddReview(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return addReviewRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useDeleteReview = function useDeleteReview(dcOrOptions, options) {
const { dc: dcInstance, vars: inputOpts } = validateArgs(connectorConfig, dcOrOptions, options);
function refFactory(vars) {
return deleteReviewRef(dcInstance, vars);
}
return useDataConnectMutation(refFactory, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useListMovies = function useListMovies(dcOrOptions, options) {
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
const ref = listMoviesRef(dcInstance);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useListUsers = function useListUsers(dcOrOptions, options) {
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
const ref = listUsersRef(dcInstance);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useListUserReviews = function useListUserReviews(dcOrOptions, options) {
const { dc: dcInstance, options: inputOpts } = validateReactArgs(connectorConfig, dcOrOptions, options);
const ref = listUserReviewsRef(dcInstance);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useGetMovieById = function useGetMovieById(dcOrVars, varsOrOptions, options) {
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, true);
const ref = getMovieByIdRef(dcInstance, inputVars);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
exports.useSearchMovie = function useSearchMovie(dcOrVars, varsOrOptions, options) {
const { dc: dcInstance, vars: inputVars, options: inputOpts } = validateReactArgs(connectorConfig, dcOrVars, varsOrOptions, options, true, false);
const ref = searchMovieRef(dcInstance, inputVars);
return useDataConnectQuery(ref, inputOpts, CallerSdkTypeEnum.GeneratedReact);
}
+33
View File
@@ -0,0 +1,33 @@
import { CreateMovieData, CreateMovieVariables, UpsertUserData, UpsertUserVariables, AddReviewData, AddReviewVariables, DeleteReviewData, DeleteReviewVariables, ListMoviesData, ListUsersData, ListUserReviewsData, GetMovieByIdData, GetMovieByIdVariables, SearchMovieData, SearchMovieVariables } from '../';
import { UseDataConnectQueryResult, useDataConnectQueryOptions, UseDataConnectMutationResult, useDataConnectMutationOptions} from '@tanstack-query-firebase/react/data-connect';
import { UseQueryResult, UseMutationResult} from '@tanstack/react-query';
import { DataConnect } from 'firebase/data-connect';
import { FirebaseError } from 'firebase/app';
export function useCreateMovie(options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
export function useCreateMovie(dc: DataConnect, options?: useDataConnectMutationOptions<CreateMovieData, FirebaseError, CreateMovieVariables>): UseDataConnectMutationResult<CreateMovieData, CreateMovieVariables>;
export function useUpsertUser(options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
export function useUpsertUser(dc: DataConnect, options?: useDataConnectMutationOptions<UpsertUserData, FirebaseError, UpsertUserVariables>): UseDataConnectMutationResult<UpsertUserData, UpsertUserVariables>;
export function useAddReview(options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
export function useAddReview(dc: DataConnect, options?: useDataConnectMutationOptions<AddReviewData, FirebaseError, AddReviewVariables>): UseDataConnectMutationResult<AddReviewData, AddReviewVariables>;
export function useDeleteReview(options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
export function useDeleteReview(dc: DataConnect, options?: useDataConnectMutationOptions<DeleteReviewData, FirebaseError, DeleteReviewVariables>): UseDataConnectMutationResult<DeleteReviewData, DeleteReviewVariables>;
export function useListMovies(options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
export function useListMovies(dc: DataConnect, options?: useDataConnectQueryOptions<ListMoviesData>): UseDataConnectQueryResult<ListMoviesData, undefined>;
export function useListUsers(options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
export function useListUsers(dc: DataConnect, options?: useDataConnectQueryOptions<ListUsersData>): UseDataConnectQueryResult<ListUsersData, undefined>;
export function useListUserReviews(options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
export function useListUserReviews(dc: DataConnect, options?: useDataConnectQueryOptions<ListUserReviewsData>): UseDataConnectQueryResult<ListUserReviewsData, undefined>;
export function useGetMovieById(vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
export function useGetMovieById(dc: DataConnect, vars: GetMovieByIdVariables, options?: useDataConnectQueryOptions<GetMovieByIdData>): UseDataConnectQueryResult<GetMovieByIdData, GetMovieByIdVariables>;
export function useSearchMovie(vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
export function useSearchMovie(dc: DataConnect, vars?: SearchMovieVariables, options?: useDataConnectQueryOptions<SearchMovieData>): UseDataConnectQueryResult<SearchMovieData, SearchMovieVariables>;
@@ -0,0 +1,17 @@
{
"name": "@dataconnect/generated-react",
"version": "1.0.0",
"author": "Firebase <firebase-support@google.com> (https://firebase.google.com/)",
"description": "Generated SDK For example",
"license": "Apache-2.0",
"engines": {
"node": " >=18.0"
},
"typings": "index.d.ts",
"main": "index.cjs.js",
"module": "esm/index.esm.js",
"browser": "esm/index.esm.js",
"peerDependencies": {
"@tanstack-query-firebase/react": "^2.0.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
import * as admin from 'firebase-admin';
if (!admin.apps.length) {
// Only initialize if we have the required environment variables
if (process.env.FIREBASE_PROJECT_ID && process.env.FIREBASE_PRIVATE_KEY && process.env.FIREBASE_CLIENT_EMAIL) {
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,
}),
});
} else {
// For build time or when env vars are not available, initialize with default credentials
// This will work for client-side operations but may not work for admin operations
admin.initializeApp();
}
}
export { admin };
+100
View File
@@ -0,0 +1,100 @@
// Import the functions you need from the SDKs you need
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore, enableIndexedDbPersistence, enableMultiTabIndexedDbPersistence } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
import { getAnalytics } from 'firebase/analytics';
import { getMessaging, onMessage, getToken } from 'firebase/messaging';
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// 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",
measurementId: "G-0C3L52N228"
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
// Initialize Firebase Authentication and get a reference to the service
export const auth = getAuth(app);
// Initialize Cloud Firestore and get a reference to the service
export const db = getFirestore(app);
// Enable offline persistence
if (typeof window !== 'undefined') {
enableMultiTabIndexedDbPersistence(db).catch((err) => {
if (err.code === 'failed-precondition') {
// Multiple tabs open, persistence can only be enabled in one tab at a time
console.warn('Firestore: Multiple tabs open, persistence disabled');
} else if (err.code === 'unimplemented') {
// The current browser doesn't support persistence
console.warn('Firestore: Persistence not supported in this browser');
}
});
}
// Initialize Firebase Storage and get a reference to the service
export const storage = getStorage(app);
// Initialize Firebase Cloud Messaging
export let messaging: any = null;
if (typeof window !== 'undefined') {
try {
messaging = getMessaging(app);
} catch (error) {
console.warn('FCM not supported in this environment:', error);
}
}
// Initialize Firebase Analytics and get a reference to the service
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let analytics: any = null;
if (typeof window !== 'undefined') {
import('firebase/analytics').then(({ getAnalytics, isSupported }) => {
isSupported().then((supported) => {
if (supported) {
analytics = getAnalytics(app);
}
});
});
}
export { analytics };
// FCM utility functions
export const requestNotificationPermission = async () => {
if (!messaging) return null;
try {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
const token = await getToken(messaging, {
vapidKey: process.env.NEXT_PUBLIC_FCM_VAPID_KEY || 'your-vapid-key-here'
});
return token;
}
} catch (error) {
console.error('Error requesting notification permission:', error);
}
return null;
};
export const onMessageListener = () =>
new Promise((resolve) => {
if (messaging) {
onMessage(messaging, (payload) => {
resolve(payload);
});
}
});
export { app };
+8
View File
@@ -0,0 +1,8 @@
export const APP_VERSION = '0.4.0';
export const WHATS_NEW_BRIEF: string[] = [
'Dashboard Navigation: tap stat cards to jump to relevant sections',
'Today\'s Prayers: shows unique prayers + total taps with details',
'Fresh Compose: no draft persistence, clean slate every time',
'Quick Access: Things to Pray For card now tappable'
];
+8
View File
@@ -8,9 +8,17 @@ export interface Prayer {
person: string;
request: string;
answer: string;
notes?: string;
status: 'active' | 'answered' | 'archived';
createdAt: string;
answeredAt?: string;
tags: string[];
prayedLog?: PrayedEntry[];
reminderDays?: string[]; // e.g. ['mon', 'wed', 'fri']
}
export interface AppSettings {
ntfyTopic?: string;
dailyDigestTime?: string; // HH:MM
reminderTime?: string; // HH:MM — time per-prayer reminders fire
}
+8
View File
@@ -65,6 +65,9 @@ export function usePrayers() {
const updateRequest = useCallback((id: string, request: string) =>
patch(id, { request }), [patch]);
const updateNotes = useCallback((id: string, notes: string) =>
patch(id, { notes }), [patch]);
const logPrayed = useCallback(async (id: string, entry: PrayedEntry) => {
const prayer = prayers.find(p => p.id === id);
if (!prayer) return;
@@ -72,6 +75,9 @@ export function usePrayers() {
await patch(id, { prayedLog });
}, [prayers, patch]);
const updateReminderDays = useCallback((id: string, reminderDays: string[]) =>
patch(id, { reminderDays }), [patch]);
const deletePrayer = useCallback(async (id: string) => {
await fetch(`${API}/${id}`, { method: 'DELETE' }).then(checkResponse);
setPrayers(prev => prev.filter(p => p.id !== id));
@@ -88,6 +94,8 @@ export function usePrayers() {
archivePrayer,
deletePrayer,
updateRequest,
updateNotes,
logPrayed,
updateReminderDays,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { useState, useEffect, useCallback } from 'react';
import { AppSettings } from './types';
const API = '/api/settings';
export function useSettings() {
const [settings, setSettings] = useState<AppSettings>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(API)
.then(r => r.json())
.then(setSettings)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const saveSettings = useCallback(async (updates: Partial<AppSettings>) => {
const res = await fetch(API, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error('Failed to save settings');
const updated = await res.json();
setSettings(updated);
return updated;
}, []);
const testNotification = useCallback(async () => {
const res = await fetch('/api/notifications/test', { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || 'Test failed');
}
}, []);
return { settings, loading, saveSettings, testNotification };
}
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
darkMode: 'class',
} satisfies Config;
+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);
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more