Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8add7ddc10 | ||
|
|
eb6cbcc3d4 | ||
|
|
5da449e6ef | ||
|
|
c36c3b1c38 | ||
|
|
497af40398 | ||
|
|
67c5ff1b35 | ||
|
|
2c29fc28d6 | ||
|
|
aa3a35bc4a | ||
|
|
74686cece1 | ||
|
|
e53e8088bb | ||
|
|
46fa6097a1 | ||
|
|
e5aa9b3e37 | ||
|
|
e2add8223c | ||
|
|
d760b34ba4 | ||
|
|
6a9237d76d | ||
|
|
7a96380ae9 | ||
|
|
9fc41fffb9 | ||
|
|
b2cea9c0a3 | ||
|
|
a1236c173d | ||
|
|
3c5da024b9 | ||
|
|
ed526b39b1 | ||
|
|
d6f4576105 | ||
|
|
61ecd7679a | ||
|
|
0704984a3c | ||
|
|
618ab3a756 | ||
|
|
a3de1aaa1a | ||
|
|
49d1bdb47d | ||
|
|
ee1daaca25 | ||
|
|
5e3a159637 | ||
|
|
90adc2eb32 | ||
|
|
81beeeee70 | ||
|
|
3de069fce9 | ||
|
|
40b61243c1 | ||
|
|
aacb2813d6 | ||
|
|
302a89e8d8 | ||
|
|
7c47901563 | ||
|
|
74ee458ba9 | ||
|
|
964e7c8aff | ||
|
|
acdc219ad3 | ||
|
|
825156a53a | ||
|
|
5b1124cd85 | ||
|
|
0cc08f7c26 | ||
|
|
a3bdb11ad7 | ||
|
|
52fd7e7c00 | ||
|
|
649daa3dda | ||
|
|
8b4109a8fd | ||
|
|
8812d3d6ab | ||
|
|
1340440c42 | ||
|
|
6c33fc4606 | ||
|
|
8105d39554 | ||
|
|
4225ef017c | ||
|
|
a67f295089 | ||
|
|
55076bbc0e | ||
|
|
1d9e2a5d38 | ||
|
|
ddad533c81 | ||
|
|
b013e972d2 | ||
|
|
bbc13237cc | ||
|
|
15e81a8957 | ||
|
|
872fb889b6 | ||
|
|
0d84231ec1 | ||
|
|
a6fcf0eb7b | ||
|
|
c18a3268fc | ||
|
|
521bb593a9 | ||
|
|
af03bf4dc8 | ||
|
|
03f2b1670f | ||
|
|
a5a03792f6 | ||
|
|
6b2d716cc4 | ||
|
|
58480cd630 | ||
|
|
bd7b9bea1a | ||
|
|
6425c155ce | ||
|
|
5afc5d754c | ||
|
|
12377187c8 | ||
|
|
159a7463cd | ||
|
|
eb9f1a6a9b | ||
|
|
db88fa52c0 | ||
|
|
b942681561 | ||
|
|
782a7a5307 | ||
|
|
6ab7ba35e3 | ||
|
|
7237aa6181 | ||
|
|
8a5658a359 | ||
|
|
9c73674c84 | ||
|
|
a5390e22be | ||
|
|
b032d14673 | ||
|
|
7293dbdc34 | ||
|
|
27d02596d1 | ||
|
|
d9784738e4 | ||
|
|
7214fdb89a | ||
|
|
a2ff235dcc | ||
|
|
5ea16bba66 | ||
|
|
1a160135d7 | ||
|
|
aa3f42479f | ||
|
|
fdfc4f5f3d | ||
|
|
9bb50b115d | ||
|
|
5ba808966e | ||
|
|
b7a1eab925 | ||
|
|
39d621a0be | ||
|
|
6a997d9931 | ||
|
|
8b189f4caa | ||
|
|
3d8ba5b8f0 | ||
|
|
1932dbe1fc | ||
|
|
9f31fd3e79 | ||
|
|
45207b5851 | ||
|
|
bc4dec65b3 | ||
|
|
5131740727 | ||
|
|
9f799397c8 | ||
|
|
6b0713612d | ||
|
|
220788ef70 | ||
|
|
0f1e8774d7 | ||
|
|
bd6e1eea7c | ||
|
|
08ca9ed7da | ||
|
|
aa2c28d444 | ||
|
|
c054fbc404 | ||
|
|
16a2a19d26 | ||
|
|
29a50dcc9b | ||
|
|
af8b346ff6 | ||
|
|
fba1097380 | ||
|
|
91d417159d |
No files matched your search
@@ -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 =
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "prayer-app-6701f"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- 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 }}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"npm install": true,
|
||||
"git add": true,
|
||||
"git commit": true
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,76 @@
|
||||
FROM node:22-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 ./
|
||||
RUN npm install
|
||||
|
||||
# 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:22-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 install --omit=dev
|
||||
COPY server.js ./
|
||||
COPY --from=build /app/build ./build
|
||||
|
||||
ENV DATA_DIR=/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"]
|
||||
@@ -1,116 +1,79 @@
|
||||
# Prayer Journal
|
||||
|
||||
A todo-list style prayer app. Add prayer requests, record how God answered them, filter between active and answered, tag entries for organization, log individual prayer sessions, and receive push notifications on your phone.
|
||||
A minimalist web app for journaling daily prayers, tracking answered prayers, and receiving push reminders. Self-hostable via Docker.
|
||||
|
||||
**Current version: 0.4.0**
|
||||
|
||||
## Features
|
||||
|
||||
- **Prayer requests** — add a person, request text, and tags
|
||||
- **Answers** — record how God answered and mark prayers complete
|
||||
- **Prayer log** — tap "I Prayed This" to log each session with optional notes
|
||||
- **Calendar view** — see your prayer activity by day
|
||||
- **Search** — full-text search across requests, answers, and tags
|
||||
- **Export** — download your journal as a Word (.docx) document
|
||||
- **Push notifications** — daily digest and per-prayer reminders via ntfy
|
||||
- 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
|
||||
|
||||
## Running with Docker
|
||||
## Stack
|
||||
|
||||
### Quick start (pull pre-built image)
|
||||
- **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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
All data is stored in the bind-mounted `/data` directory:
|
||||
## Push notifications (ntfy)
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `prayers.json` | All prayer requests and logs |
|
||||
| `settings.json` | Notification settings (ntfy topic, times) |
|
||||
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.
|
||||
|
||||
The default host path is `/media/ZimaOS-HD/AppData/Prayer/Data`. Change the `volumes` line in `docker-compose.yml` to match your server.
|
||||
|
||||
### Timezone
|
||||
|
||||
Notification times (daily digest, reminders) are evaluated in the container's timezone. Set the `TZ` environment variable in `docker-compose.yml` to your local timezone so times fire when you expect:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- TZ=America/Chicago # or America/New_York, Europe/London, etc.
|
||||
```
|
||||
|
||||
Restart the container after changing the timezone:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Push Notifications (ntfy)
|
||||
|
||||
Prayer Journal sends notifications via [ntfy.sh](https://ntfy.sh) — a free, open-source push service that works without an account.
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install the ntfy app** on your phone — search "ntfy" on the App Store or Google Play, or visit [ntfy.sh](https://ntfy.sh)
|
||||
2. **Open the app** and add a subscription to your chosen topic name (e.g. `my-prayers-7x3q9`). Pick something unique and hard to guess — anyone who knows the topic name can subscribe and read your notifications, so avoid obvious names like `prayer`.
|
||||
3. **Open Prayer Journal** in the browser and click **🔔 Notifications** in the header
|
||||
4. **Enter the same topic name** and configure:
|
||||
- **Daily digest time** — receive a list of all active prayers at this time each day (leave blank to disable)
|
||||
- **Per-prayer reminder time** — the time of day individual prayer reminders fire
|
||||
5. **Click "Send test"** to verify everything is working
|
||||
|
||||
### Per-prayer reminders
|
||||
|
||||
On any active prayer card, expand it and use the **🔔 Remind me** row to pick which days of the week you want a reminder for that prayer. The reminder fires at the per-prayer reminder time configured in settings.
|
||||
|
||||
### No account required
|
||||
|
||||
ntfy.sh is a free public service — no sign-up needed. The app POSTs to `https://ntfy.sh/<your-topic>` and your phone subscribes to the same URL. Self-hosting is optional.
|
||||
|
||||
### Self-hosted ntfy
|
||||
|
||||
If you run your own ntfy server, enter the full URL as the topic:
|
||||
|
||||
```
|
||||
https://your-ntfy-server.example.com/my-topic
|
||||
```
|
||||
|
||||
## CI / CD
|
||||
|
||||
Pushes to `main` trigger a GitHub Actions workflow (`.github/workflows/docker.yml`) that:
|
||||
|
||||
1. Builds the Docker image (multi-stage: Node 22 build → Node 22 runtime)
|
||||
2. Pushes to GitHub Container Registry (`ghcr.io/nmemmert/prayer`)
|
||||
3. Tags the image as `latest`, the branch name, and the short commit SHA
|
||||
|
||||
The `GITHUB_TOKEN` secret is used automatically — no additional secrets required.
|
||||
|
||||
> **Note:** Commits made by Claude will appear as "Unverified" on GitHub because they are not GPG-signed. This is expected and does not affect functionality.
|
||||
- **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
|
||||
node server.js # API + notification server on :4000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Stack
|
||||
App runs at `http://localhost:3000`.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | React 18, TypeScript |
|
||||
| Backend | Node.js, Express |
|
||||
| Notifications | ntfy.sh (via node-cron + fetch) |
|
||||
| Data | JSON files on a bind-mounted host directory |
|
||||
| Images | GitHub Container Registry (ghcr) |
|
||||
| CI/CD | GitHub Actions |
|
||||
```bash
|
||||
npm run build # production build
|
||||
npm run lint # lint
|
||||
npm test # run tests
|
||||
```
|
||||
|
||||
## CI / Deployment
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,8 @@
|
||||
connectorId: example
|
||||
generate:
|
||||
javascriptSdk:
|
||||
- outputDir: ..\..\src\dataconnect-generated
|
||||
package: "@dataconnect/generated"
|
||||
packageJsonDir: ..\..
|
||||
react: true
|
||||
angular: false
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,27 +1,44 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
prayer:
|
||||
image: ghcr.io/nmemmert/prayer:latest
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "3000:4000"
|
||||
volumes:
|
||||
# Bind mount to host path — data survives container recreation.
|
||||
# ZimaOS default: /media/ZimaOS-HD/AppData/Prayer/Data
|
||||
# Change this path to match your host setup.
|
||||
- /media/ZimaOS-HD/AppData/Prayer/Data:/data
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- PORT=4000
|
||||
# Optional: set your timezone so daily-digest and reminder times
|
||||
# fire at the correct local time. Examples:
|
||||
# TZ=America/Chicago
|
||||
# TZ=America/New_York
|
||||
# TZ=Europe/London
|
||||
- TZ=America/Chicago
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
To: {{to_email}}
|
||||
From: {{from_name}}
|
||||
Subject: {{subject}}
|
||||
|
||||
{{message}}
|
||||
|
||||
Prayer Details:
|
||||
- Type: {{prayer_type}}
|
||||
- Date: {{prayer_date}}
|
||||
- Text: {{prayer_text}}
|
||||
- Journal: {{prayer_journal}}
|
||||
- Praying for: {{praying_for}}
|
||||
- Reminder frequency: {{reminder_frequency}}
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Node.js dependency directory
|
||||
node_modules/
|
||||
*.local
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"include": [
|
||||
".eslintrc.js"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const nextJest = require('next/jest')
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files
|
||||
dir: './',
|
||||
})
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapping: {
|
||||
// Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths)
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
}
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en', 'es', 'fr'], // Add more as needed
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1,50 +1,53 @@
|
||||
{
|
||||
"name": "prayer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"docx": "^9.7.1",
|
||||
"express": "^5.2.1",
|
||||
"node-cron": "^4.6.0"
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@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",
|
||||
"@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",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -0,0 +1,31 @@
|
||||
// Import Firebase scripts
|
||||
importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-messaging-compat.js');
|
||||
|
||||
// Your web app's Firebase configuration
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyAHtvhdtBxNwf0UE3Fp5P0v1vITZKhLMJw",
|
||||
authDomain: "prayer-app-6701f.firebaseapp.com",
|
||||
projectId: "prayer-app-6701f",
|
||||
storageBucket: "prayer-app-6701f.firebasestorage.app",
|
||||
messagingSenderId: "555011794707",
|
||||
appId: "1:555011794707:web:a3d60bdee742578cca1ce6"
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
// Handle background messages
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
console.log('Received background message ', payload);
|
||||
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192x192.png' // Update with your app icon
|
||||
};
|
||||
|
||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<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 |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"prayer": "Prayer",
|
||||
"praise": "Praise",
|
||||
"add": "Add",
|
||||
"active": "Active",
|
||||
"archived": "Archived",
|
||||
"search": "Search",
|
||||
"community": "Community",
|
||||
"profile": "Profile",
|
||||
"logout": "Logout",
|
||||
"share": "Share",
|
||||
"export": "Export"
|
||||
}
|
||||
@@ -1,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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 |
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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")');
|
||||
@@ -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();
|
||||
});
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'));
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
```
|
||||
@@ -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"}
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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'
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
@@ -8,19 +8,37 @@
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts",
|
||||
".next\\dev/types/**/*.ts",
|
||||
".next\\dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
declare module 'next-pwa' {
|
||||
import { NextConfig } from 'next';
|
||||
|
||||
interface PWAConfig {
|
||||
dest?: string;
|
||||
disable?: boolean;
|
||||
register?: boolean;
|
||||
scope?: string;
|
||||
sw?: string;
|
||||
runtimeCaching?: any[];
|
||||
buildExcludes?: string[];
|
||||
publicExcludes?: string[];
|
||||
fallbacks?: Record<string, string>;
|
||||
cacheOnFrontEndNav?: boolean;
|
||||
reloadOnOnline?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function withPWA(config?: PWAConfig): (nextConfig: NextConfig) => NextConfig;
|
||||
|
||||
export = withPWA;
|
||||
}
|
||||