diff --git a/app/[locale]/reading-plans/[id]/page.tsx b/app/[locale]/reading-plans/[id]/page.tsx new file mode 100644 index 0000000..cf9969f --- /dev/null +++ b/app/[locale]/reading-plans/[id]/page.tsx @@ -0,0 +1,469 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useLocale } from 'next-intl' +import { useAuth } from '@/hooks/use-auth' +import { ProtectedRoute } from '@/components/auth/protected-route' +import { + Container, + Box, + Typography, + Card, + CardContent, + Button, + Chip, + CircularProgress, + Alert, + LinearProgress, + IconButton, + List, + ListItem, + ListItemText, + ListItemIcon, + Checkbox, + Paper, + Divider, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions +} from '@mui/material' +import Grid from '@mui/material/Grid' +import { + ArrowBack, + CheckCircle, + RadioButtonUnchecked, + CalendarToday, + LocalFireDepartment, + EmojiEvents, + TrendingUp, + Edit, + Save +} from '@mui/icons-material' +import Link from 'next/link' + +interface UserPlan { + id: string + name: string + startDate: string + targetEndDate: string + status: string + currentDay: number + completedDays: number + streak: number + longestStreak: number + plan?: { + id: string + name: string + description: string + duration: number + schedule: any + } + customSchedule?: any + progress: ProgressEntry[] +} + +interface ProgressEntry { + id: string + planDay: number + bookId: string + chapterNum: number + versesRead: string | null + completed: boolean + notes: string | null + date: string +} + +export default function ReadingPlanDetailPage() { + const params = useParams() + const router = useRouter() + const locale = useLocale() + const { user } = useAuth() + + const [loading, setLoading] = useState(true) + const [plan, setPlan] = useState(null) + const [error, setError] = useState('') + const [notesDialog, setNotesDialog] = useState<{ open: boolean; day: number; notes: string }>({ + open: false, + day: 0, + notes: '' + }) + + useEffect(() => { + loadPlan() + }, [params.id]) + + const loadPlan = async () => { + setLoading(true) + setError('') + + try { + const token = localStorage.getItem('authToken') + if (!token) { + router.push(`/${locale}/login`) + return + } + + const response = await fetch(`/api/user/reading-plans/${params.id}`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + + const data = await response.json() + + if (data.success) { + setPlan(data.plan) + } else { + setError(data.error || 'Failed to load reading plan') + } + } catch (err) { + console.error('Error loading plan:', err) + setError('Failed to load reading plan') + } finally { + setLoading(false) + } + } + + const markDayComplete = async (day: number, reading: any) => { + const token = localStorage.getItem('authToken') + if (!token) return + + try { + const response = await fetch(`/api/user/reading-plans/${params.id}/progress`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + planDay: day, + bookId: reading.book, + chapterNum: reading.chapter, + versesRead: reading.verses || null, + completed: true + }) + }) + + const data = await response.json() + + if (data.success) { + loadPlan() // Reload to get updated progress + } else { + setError(data.error || 'Failed to mark reading complete') + } + } catch (err) { + console.error('Error marking reading complete:', err) + setError('Failed to mark reading complete') + } + } + + const saveNotes = async () => { + const token = localStorage.getItem('authToken') + if (!token) return + + const schedule = plan?.plan?.schedule || plan?.customSchedule + if (!schedule || !Array.isArray(schedule)) return + + const daySchedule = schedule[notesDialog.day - 1] + if (!daySchedule || !daySchedule.readings || daySchedule.readings.length === 0) return + + const reading = daySchedule.readings[0] + + try { + const response = await fetch(`/api/user/reading-plans/${params.id}/progress`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + planDay: notesDialog.day, + bookId: reading.book, + chapterNum: reading.chapter, + notes: notesDialog.notes + }) + }) + + const data = await response.json() + + if (data.success) { + setNotesDialog({ open: false, day: 0, notes: '' }) + loadPlan() + } else { + setError(data.error || 'Failed to save notes') + } + } catch (err) { + console.error('Error saving notes:', err) + setError('Failed to save notes') + } + } + + const isDayCompleted = (day: number) => { + if (!plan) return false + return plan.progress.some(p => p.planDay === day && p.completed) + } + + const getDayNotes = (day: number) => { + if (!plan) return '' + const entry = plan.progress.find(p => p.planDay === day) + return entry?.notes || '' + } + + if (loading) { + return ( + + + + + + + + ) + } + + if (!plan) { + return ( + + + Reading plan not found + + + + ) + } + + const schedule = plan.plan?.schedule || plan.customSchedule + const duration = plan.plan?.duration || (Array.isArray(schedule) ? schedule.length : 365) + const progressPercentage = (plan.completedDays / duration) * 100 + + return ( + + + {/* Header */} + + + + + + {plan.name} + + + + + {plan.plan?.description && ( + + {plan.plan.description} + + )} + + + {error && ( + setError('')}> + {error} + + )} + + {/* Statistics Cards */} + + + + + + + + Progress + + + + {Math.round(progressPercentage)}% + + + + {plan.completedDays} / {duration} days + + + + + + + + + + + + Current Streak + + + + {plan.streak} + + + days in a row + + + + + + + + + + + + Best Streak + + + + {plan.longestStreak} + + + days record + + + + + + + + + + + + Target Date + + + + {new Date(plan.targetEndDate).toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + year: 'numeric' + })} + + + + + + + {/* Reading Schedule */} + + + Reading Schedule + + + + + {Array.isArray(schedule) && schedule.map((daySchedule: any, index: number) => { + const day = index + 1 + const isCompleted = isDayCompleted(day) + const isCurrent = day === plan.currentDay + const notes = getDayNotes(day) + + return ( + + setNotesDialog({ open: true, day, notes })} + > + + + { + if (!isCompleted && daySchedule.readings && daySchedule.readings.length > 0) { + markDayComplete(day, daySchedule.readings[0]) + } + }} + icon={} + checkedIcon={} + /> + + } + > + + + + + {daySchedule.readings?.map((reading: any, i: number) => ( + + {reading.book} {reading.chapter} + {reading.verses && `:${reading.verses}`} + {i < daySchedule.readings.length - 1 && ', '} + + ))} + + } + secondary={notes && `Notes: ${notes}`} + /> + + ) + })} + + + {(!schedule || !Array.isArray(schedule)) && ( + + No schedule available for this plan + + )} + + + {/* Notes Dialog */} + setNotesDialog({ open: false, day: 0, notes: '' })} maxWidth="sm" fullWidth> + Add Notes - Day {notesDialog.day} + + setNotesDialog({ ...notesDialog, notes: e.target.value })} + placeholder="Add your thoughts, insights, or reflections..." + sx={{ mt: 2 }} + /> + + + + + + + + + ) +} diff --git a/app/[locale]/reading-plans/page.tsx b/app/[locale]/reading-plans/page.tsx new file mode 100644 index 0000000..a232ac4 --- /dev/null +++ b/app/[locale]/reading-plans/page.tsx @@ -0,0 +1,437 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useTranslations, useLocale } from 'next-intl' +import { useAuth } from '@/hooks/use-auth' +import { ProtectedRoute } from '@/components/auth/protected-route' +import { + Container, + Box, + Typography, + Card, + CardContent, + CardActions, + Button, + Chip, + CircularProgress, + Alert, + Tabs, + Tab, + LinearProgress, + IconButton +} from '@mui/material' +import Grid from '@mui/material/Grid' +import { + MenuBook, + PlayArrow, + Pause, + CheckCircle, + Add, + CalendarToday, + TrendingUp, + Delete, + Settings +} from '@mui/icons-material' +import Link from 'next/link' +import { useRouter } from 'next/navigation' + +interface ReadingPlan { + id: string + name: string + description: string + duration: number + difficulty: string + type: string +} + +interface UserPlan { + id: string + name: string + startDate: string + targetEndDate: string + status: string + currentDay: number + completedDays: number + streak: number + longestStreak: number + plan?: ReadingPlan +} + +export default function ReadingPlansPage() { + const { user } = useAuth() + const locale = useLocale() + const router = useRouter() + const t = useTranslations('readingPlans') + + const [loading, setLoading] = useState(true) + const [availablePlans, setAvailablePlans] = useState([]) + const [userPlans, setUserPlans] = useState([]) + const [error, setError] = useState('') + const [tabValue, setTabValue] = useState(0) + + useEffect(() => { + loadData() + }, [locale]) + + const loadData = async () => { + setLoading(true) + setError('') + + try { + const token = localStorage.getItem('authToken') + + // Load available plans + const plansRes = await fetch(`/api/reading-plans?language=${locale}`) + const plansData = await plansRes.json() + + if (plansData.success) { + setAvailablePlans(plansData.plans) + } + + // Load user's plans if authenticated + if (token) { + const userPlansRes = await fetch('/api/user/reading-plans?status=ALL', { + headers: { 'Authorization': `Bearer ${token}` } + }) + const userPlansData = await userPlansRes.json() + + if (userPlansData.success) { + setUserPlans(userPlansData.plans) + } + } + } catch (err) { + console.error('Error loading reading plans:', err) + setError('Failed to load reading plans') + } finally { + setLoading(false) + } + } + + const enrollInPlan = async (planId: string) => { + const token = localStorage.getItem('authToken') + if (!token) { + router.push(`/${locale}/login`) + return + } + + try { + const response = await fetch('/api/user/reading-plans', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ planId }) + }) + + const data = await response.json() + + if (data.success) { + loadData() // Reload data + setTabValue(1) // Switch to My Plans tab + } else { + setError(data.error || 'Failed to enroll in plan') + } + } catch (err) { + console.error('Error enrolling in plan:', err) + setError('Failed to enroll in plan') + } + } + + const updatePlanStatus = async (planId: string, status: string) => { + const token = localStorage.getItem('authToken') + if (!token) return + + try { + const response = await fetch(`/api/user/reading-plans/${planId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ status }) + }) + + const data = await response.json() + + if (data.success) { + loadData() // Reload data + } else { + setError(data.error || 'Failed to update plan') + } + } catch (err) { + console.error('Error updating plan:', err) + setError('Failed to update plan') + } + } + + const deletePlan = async (planId: string) => { + if (!confirm('Are you sure you want to delete this reading plan? This action cannot be undone.')) { + return + } + + const token = localStorage.getItem('authToken') + if (!token) return + + try { + const response = await fetch(`/api/user/reading-plans/${planId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }) + + const data = await response.json() + + if (data.success) { + loadData() // Reload data + } else { + setError(data.error || 'Failed to delete plan') + } + } catch (err) { + console.error('Error deleting plan:', err) + setError('Failed to delete plan') + } + } + + const getDifficultyColor = (difficulty: string) => { + switch (difficulty.toLowerCase()) { + case 'beginner': return 'success' + case 'intermediate': return 'warning' + case 'advanced': return 'error' + default: return 'default' + } + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'ACTIVE': return 'primary' + case 'COMPLETED': return 'success' + case 'PAUSED': return 'warning' + case 'CANCELLED': return 'error' + default: return 'default' + } + } + + if (loading) { + return ( + + + + + + + + ) + } + + return ( + + + {/* Header */} + + + + Reading Plans + + + Stay consistent in your Bible reading with structured reading plans + + + + {error && ( + setError('')}> + {error} + + )} + + {/* Tabs */} + + setTabValue(v)}> + + + + + + {/* Available Plans Tab */} + {tabValue === 0 && ( + + {availablePlans.map((plan) => ( + + + + + {plan.name} + + + } + /> + + + + {plan.description} + + + + + + + + ))} + + {availablePlans.length === 0 && ( + + + + No reading plans available for this language yet. + + + + )} + + )} + + {/* My Plans Tab */} + {tabValue === 1 && ( + + {userPlans.map((userPlan) => { + const progress = userPlan.completedDays / (userPlan.plan?.duration || 365) * 100 + return ( + + + + + + {userPlan.name} + + + + + + + + Progress + + + {userPlan.completedDays} / {userPlan.plan?.duration || 365} days + + + + + + + + + + Current Streak + + + {userPlan.streak} days + + + + + + + Best Streak + + + {userPlan.longestStreak} days + + + + + + + + + {userPlan.status === 'ACTIVE' && ( + updatePlanStatus(userPlan.id, 'PAUSED')} + title="Pause" + > + + + )} + + {userPlan.status === 'PAUSED' && ( + updatePlanStatus(userPlan.id, 'ACTIVE')} + title="Resume" + > + + + )} + + {userPlan.status !== 'COMPLETED' && ( + deletePlan(userPlan.id)} + title="Delete" + color="error" + > + + + )} + + + + + ) + })} + + {userPlans.length === 0 && ( + + + + You haven't enrolled in any reading plans yet. + + + + + )} + + )} + + + ) +} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 6f7affd..0780ee7 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -126,11 +126,37 @@ export default function SettingsPage() { } const handleSave = async () => { + const token = localStorage.getItem('authToken') + if (!token) { + setMessage(t('settingsError')) + return + } + try { - // TODO: Implement settings update API - await new Promise(resolve => setTimeout(resolve, 1000)) // Placeholder - setMessage(t('settingsSaved')) + const response = await fetch(`/api/user/settings?locale=${locale}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + theme: settings.theme, + fontSize: settings.fontSize, + notifications: settings.notifications, + emailUpdates: settings.emailUpdates, + language: settings.language + }) + }) + + const data = await response.json() + + if (response.ok && data.success) { + setMessage(t('settingsSaved')) + } else { + setMessage(data.error || t('settingsError')) + } } catch (error) { + console.error('Error saving settings:', error) setMessage(t('settingsError')) } } diff --git a/app/api/reading-plans/route.ts b/app/api/reading-plans/route.ts new file mode 100644 index 0000000..0c36397 --- /dev/null +++ b/app/api/reading-plans/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/db' + +export const runtime = 'nodejs' + +/** + * GET /api/reading-plans + * Get all available predefined reading plans + */ +export async function GET(request: Request) { + try { + const url = new URL(request.url) + const language = url.searchParams.get('language') || 'en' + + const plans = await prisma.readingPlan.findMany({ + where: { + isActive: true, + type: 'PREDEFINED', + language: language + }, + select: { + id: true, + name: true, + description: true, + duration: true, + difficulty: true, + language: true, + type: true, + createdAt: true + }, + orderBy: { + duration: 'asc' + } + }) + + return NextResponse.json({ + success: true, + plans + }) + + } catch (error) { + console.error('Reading plans fetch error:', error) + return NextResponse.json( + { error: 'Failed to fetch reading plans' }, + { status: 500 } + ) + } +} diff --git a/app/api/user/reading-plans/[id]/progress/route.ts b/app/api/user/reading-plans/[id]/progress/route.ts new file mode 100644 index 0000000..3ae760c --- /dev/null +++ b/app/api/user/reading-plans/[id]/progress/route.ts @@ -0,0 +1,234 @@ +import { NextResponse } from 'next/server' +import { getUserFromToken } from '@/lib/auth' +import { prisma } from '@/lib/db' + +export const runtime = 'nodejs' + +/** + * GET /api/user/reading-plans/[id]/progress + * Get progress for a specific reading plan + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify plan belongs to user + const userPlan = await prisma.userReadingPlan.findUnique({ + where: { + id, + userId: user.id + } + }) + + if (!userPlan) { + return NextResponse.json( + { error: 'Reading plan not found' }, + { status: 404 } + ) + } + + const progress = await prisma.userReadingProgress.findMany({ + where: { + userPlanId: id, + userId: user.id + }, + orderBy: { + planDay: 'asc' + } + }) + + return NextResponse.json({ + success: true, + progress + }) + + } catch (error) { + console.error('Reading progress fetch error:', error) + return NextResponse.json( + { error: 'Failed to fetch reading progress' }, + { status: 500 } + ) + } +} + +/** + * POST /api/user/reading-plans/[id]/progress + * Mark a reading as complete + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { planDay, bookId, chapterNum, versesRead, completed, notes } = body + + // Validate required fields + if (!planDay || !bookId || !chapterNum) { + return NextResponse.json( + { error: 'planDay, bookId, and chapterNum are required' }, + { status: 400 } + ) + } + + // Verify plan belongs to user + const userPlan = await prisma.userReadingPlan.findUnique({ + where: { + id, + userId: user.id + } + }) + + if (!userPlan) { + return NextResponse.json( + { error: 'Reading plan not found' }, + { status: 404 } + ) + } + + // Create or update progress entry + const progress = await prisma.userReadingProgress.upsert({ + where: { + userPlanId_planDay_bookId_chapterNum: { + userPlanId: id, + planDay: parseInt(planDay), + bookId: bookId, + chapterNum: parseInt(chapterNum) + } + }, + create: { + userId: user.id, + userPlanId: id, + planDay: parseInt(planDay), + bookId: bookId, + chapterNum: parseInt(chapterNum), + versesRead: versesRead || null, + completed: completed !== false, + notes: notes || null + }, + update: { + completed: completed !== false, + versesRead: versesRead || null, + notes: notes || null, + updatedAt: new Date() + } + }) + + // Update user plan statistics + if (completed !== false) { + // Count total completed days + const completedDays = await prisma.userReadingProgress.count({ + where: { + userPlanId: id, + userId: user.id, + completed: true + } + }) + + // Calculate streak + const allProgress = await prisma.userReadingProgress.findMany({ + where: { + userPlanId: id, + userId: user.id, + completed: true + }, + orderBy: { + date: 'desc' + }, + select: { + date: true + } + }) + + let currentStreak = 0 + let longestStreak = 0 + let tempStreak = 0 + let lastDate: Date | null = null + + for (const entry of allProgress) { + if (!lastDate) { + tempStreak = 1 + lastDate = new Date(entry.date) + } else { + const dayDiff = Math.floor((lastDate.getTime() - new Date(entry.date).getTime()) / (1000 * 60 * 60 * 24)) + if (dayDiff === 1) { + tempStreak++ + } else { + if (tempStreak > longestStreak) { + longestStreak = tempStreak + } + tempStreak = 1 + } + lastDate = new Date(entry.date) + } + } + + currentStreak = tempStreak + if (currentStreak > longestStreak) { + longestStreak = currentStreak + } + + // Update current day if this is the latest completed day + const maxDay = parseInt(planDay) + const shouldUpdateCurrentDay = maxDay >= userPlan.currentDay + + await prisma.userReadingPlan.update({ + where: { id }, + data: { + completedDays: completedDays, + streak: currentStreak, + longestStreak: Math.max(longestStreak, userPlan.longestStreak), + ...(shouldUpdateCurrentDay && { currentDay: maxDay + 1 }) + } + }) + } + + return NextResponse.json({ + success: true, + message: 'Reading progress updated successfully', + progress + }) + + } catch (error) { + console.error('Reading progress update error:', error) + return NextResponse.json( + { error: 'Failed to update reading progress' }, + { status: 500 } + ) + } +} diff --git a/app/api/user/reading-plans/[id]/route.ts b/app/api/user/reading-plans/[id]/route.ts new file mode 100644 index 0000000..c3d077f --- /dev/null +++ b/app/api/user/reading-plans/[id]/route.ts @@ -0,0 +1,230 @@ +import { NextResponse } from 'next/server' +import { getUserFromToken } from '@/lib/auth' +import { prisma } from '@/lib/db' + +export const runtime = 'nodejs' + +/** + * GET /api/user/reading-plans/[id] + * Get a specific reading plan with progress + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userPlan = await prisma.userReadingPlan.findUnique({ + where: { + id, + userId: user.id + }, + include: { + plan: { + select: { + id: true, + name: true, + description: true, + duration: true, + difficulty: true, + schedule: true, + type: true + } + }, + progress: { + orderBy: { + planDay: 'asc' + } + } + } + }) + + if (!userPlan) { + return NextResponse.json( + { error: 'Reading plan not found' }, + { status: 404 } + ) + } + + return NextResponse.json({ + success: true, + plan: userPlan + }) + + } catch (error) { + console.error('Reading plan fetch error:', error) + return NextResponse.json( + { error: 'Failed to fetch reading plan' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/user/reading-plans/[id] + * Update a reading plan (pause, resume, complete, cancel) + */ +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { status, reminderEnabled, reminderTime } = body + + // Verify plan belongs to user + const existingPlan = await prisma.userReadingPlan.findUnique({ + where: { + id, + userId: user.id + } + }) + + if (!existingPlan) { + return NextResponse.json( + { error: 'Reading plan not found' }, + { status: 404 } + ) + } + + const updateData: any = {} + + if (status !== undefined) { + const validStatuses = ['ACTIVE', 'COMPLETED', 'PAUSED', 'CANCELLED'] + if (!validStatuses.includes(status)) { + return NextResponse.json( + { error: 'Invalid status' }, + { status: 400 } + ) + } + updateData.status = status + + // If completing, set actualEndDate + if (status === 'COMPLETED') { + updateData.actualEndDate = new Date() + } + } + + if (reminderEnabled !== undefined) { + updateData.reminderEnabled = reminderEnabled + } + + if (reminderTime !== undefined) { + updateData.reminderTime = reminderTime + } + + const updatedPlan = await prisma.userReadingPlan.update({ + where: { id }, + data: updateData, + include: { + plan: true + } + }) + + return NextResponse.json({ + success: true, + message: 'Reading plan updated successfully', + plan: updatedPlan + }) + + } catch (error) { + console.error('Reading plan update error:', error) + return NextResponse.json( + { error: 'Failed to update reading plan' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/user/reading-plans/[id] + * Delete a reading plan + */ +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify plan belongs to user + const existingPlan = await prisma.userReadingPlan.findUnique({ + where: { + id, + userId: user.id + } + }) + + if (!existingPlan) { + return NextResponse.json( + { error: 'Reading plan not found' }, + { status: 404 } + ) + } + + // Delete the plan (cascade will delete progress too) + await prisma.userReadingPlan.delete({ + where: { id } + }) + + return NextResponse.json({ + success: true, + message: 'Reading plan deleted successfully' + }) + + } catch (error) { + console.error('Reading plan delete error:', error) + return NextResponse.json( + { error: 'Failed to delete reading plan' }, + { status: 500 } + ) + } +} diff --git a/app/api/user/reading-plans/route.ts b/app/api/user/reading-plans/route.ts new file mode 100644 index 0000000..037add9 --- /dev/null +++ b/app/api/user/reading-plans/route.ts @@ -0,0 +1,181 @@ +import { NextResponse } from 'next/server' +import { getUserFromToken } from '@/lib/auth' +import { prisma } from '@/lib/db' + +export const runtime = 'nodejs' + +/** + * GET /api/user/reading-plans + * Get all reading plans for the authenticated user + */ +export async function GET(request: Request) { + try { + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const url = new URL(request.url) + const status = url.searchParams.get('status') || 'ACTIVE' + + const userPlans = await prisma.userReadingPlan.findMany({ + where: { + userId: user.id, + ...(status !== 'ALL' && { status: status as any }) + }, + include: { + plan: { + select: { + id: true, + name: true, + description: true, + duration: true, + difficulty: true, + type: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }) + + return NextResponse.json({ + success: true, + plans: userPlans + }) + + } catch (error) { + console.error('User reading plans fetch error:', error) + return NextResponse.json( + { error: 'Failed to fetch reading plans' }, + { status: 500 } + ) + } +} + +/** + * POST /api/user/reading-plans + * Enroll user in a reading plan + */ +export async function POST(request: Request) { + try { + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { planId, startDate, customSchedule, name } = body + + // Validate input + if (!planId && !customSchedule) { + return NextResponse.json( + { error: 'Either planId or customSchedule is required' }, + { status: 400 } + ) + } + + let planData: any = {} + let duration = 365 // Default duration + + if (planId) { + // Enrolling in a predefined plan + const plan = await prisma.readingPlan.findUnique({ + where: { id: planId } + }) + + if (!plan) { + return NextResponse.json( + { error: 'Reading plan not found' }, + { status: 404 } + ) + } + + if (!plan.isActive) { + return NextResponse.json( + { error: 'This reading plan is no longer available' }, + { status: 400 } + ) + } + + duration = plan.duration + planData = { + planId: plan.id, + name: plan.name + } + } else { + // Creating a custom plan + if (!name) { + return NextResponse.json( + { error: 'Name is required for custom plans' }, + { status: 400 } + ) + } + + if (!customSchedule || !Array.isArray(customSchedule)) { + return NextResponse.json( + { error: 'Valid customSchedule is required' }, + { status: 400 } + ) + } + + duration = customSchedule.length + planData = { + name, + customSchedule + } + } + + // Calculate target end date + const start = startDate ? new Date(startDate) : new Date() + const targetEnd = new Date(start) + targetEnd.setDate(targetEnd.getDate() + duration) + + // Create user reading plan + const userPlan = await prisma.userReadingPlan.create({ + data: { + userId: user.id, + startDate: start, + targetEndDate: targetEnd, + ...planData + }, + include: { + plan: true + } + }) + + return NextResponse.json({ + success: true, + message: 'Successfully enrolled in reading plan', + plan: userPlan + }) + + } catch (error) { + console.error('Reading plan enrollment error:', error) + return NextResponse.json( + { error: 'Failed to enroll in reading plan' }, + { status: 500 } + ) + } +} diff --git a/app/api/user/settings/route.ts b/app/api/user/settings/route.ts new file mode 100644 index 0000000..de25c74 --- /dev/null +++ b/app/api/user/settings/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from 'next/server' +import { getUserFromToken } from '@/lib/auth' +import { prisma } from '@/lib/db' + +export const runtime = 'nodejs' + +function getErrorMessages(locale: string = 'ro') { + const messages = { + ro: { + unauthorized: 'Nu esti autentificat', + updateFailed: 'Actualizarea setărilor a eșuat', + success: 'Setări actualizate cu succes', + invalidData: 'Date invalide' + }, + en: { + unauthorized: 'Unauthorized', + updateFailed: 'Settings update failed', + success: 'Settings updated successfully', + invalidData: 'Invalid data' + } + } + return messages[locale as keyof typeof messages] || messages.ro +} + +export async function PUT(request: Request) { + try { + const url = new URL(request.url) + const locale = url.searchParams.get('locale') || 'ro' + const messages = getErrorMessages(locale) + + // Get token from authorization header + const authHeader = request.headers.get('authorization') + const token = authHeader?.replace('Bearer ', '') + + if (!token) { + return NextResponse.json({ error: messages.unauthorized }, { status: 401 }) + } + + // Verify token and get user + const user = await getUserFromToken(token) + + if (!user) { + return NextResponse.json({ error: messages.unauthorized }, { status: 401 }) + } + + // Parse request body + const body = await request.json() + const { theme, fontSize, notifications, emailUpdates, language } = body + + // Validate input - allow partial updates + const updateData: any = {} + + if (theme !== undefined) { + if (!['light', 'dark', 'auto'].includes(theme)) { + return NextResponse.json({ error: messages.invalidData }, { status: 400 }) + } + updateData.theme = theme + } + + if (fontSize !== undefined) { + if (!['small', 'medium', 'large'].includes(fontSize)) { + return NextResponse.json({ error: messages.invalidData }, { status: 400 }) + } + updateData.fontSize = fontSize + } + + // Note: notifications and emailUpdates would need additional columns in User model + // For now, we'll skip them or store in a JSON field if needed + + // Update user settings + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: updateData, + select: { + id: true, + email: true, + name: true, + role: true, + theme: true, + fontSize: true, + subscriptionTier: true, + subscriptionStatus: true, + conversationLimit: true, + conversationCount: true, + limitResetDate: true + } + }) + + return NextResponse.json({ + success: true, + message: messages.success, + user: updatedUser + }) + + } catch (error) { + console.error('Settings update error:', error) + const url = new URL(request.url) + const locale = url.searchParams.get('locale') || 'ro' + const messages = getErrorMessages(locale) + + return NextResponse.json({ error: messages.updateFailed }, { status: 500 }) + } +} diff --git a/components/layout/navigation.tsx b/components/layout/navigation.tsx index 4858f88..277163f 100644 --- a/components/layout/navigation.tsx +++ b/components/layout/navigation.tsx @@ -32,6 +32,7 @@ import { Logout, Login, Bookmark, + CalendarToday, } from '@mui/icons-material' import { useRouter } from 'next/navigation' import { useTranslations, useLocale } from 'next-intl' @@ -92,6 +93,7 @@ export function Navigation() { const authenticatedPages = [ ...pages, { name: t('bookmarks'), path: '/bookmarks', icon: }, + { name: t('readingPlans'), path: '/reading-plans', icon: }, ] const settings = [ diff --git a/messages/en.json b/messages/en.json index ae93ca3..a9b4192 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,6 +5,7 @@ "prayers": "Prayers", "search": "Search", "bookmarks": "Bookmarks", + "readingPlans": "Reading Plans", "profile": "Profile", "settings": "Settings", "logout": "Logout", diff --git a/messages/es.json b/messages/es.json index c7f56af..95644fc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5,6 +5,7 @@ "prayers": "Oraciones", "search": "Buscar", "bookmarks": "Marcadores", + "readingPlans": "Planes de Lectura", "profile": "Perfil", "settings": "Configuración", "logout": "Cerrar sesión", diff --git a/messages/it.json b/messages/it.json index dbe4080..f8d7c20 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5,6 +5,7 @@ "prayers": "Preghiere", "search": "Cerca", "bookmarks": "Segnalibri", + "readingPlans": "Piani di Lettura", "profile": "Profilo", "settings": "Impostazioni", "logout": "Disconnetti", diff --git a/messages/ro.json b/messages/ro.json index ab301a7..fcf5379 100644 --- a/messages/ro.json +++ b/messages/ro.json @@ -5,6 +5,7 @@ "prayers": "Rugăciuni", "search": "Căutare", "bookmarks": "Favorite", + "readingPlans": "Planuri de Lectură", "profile": "Profil", "settings": "Setări", "logout": "Deconectare", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d9142fe..9d84cd6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,8 @@ model User { userPrayers UserPrayer[] readingHistory ReadingHistory[] preferences UserPreference[] + userReadingPlans UserReadingPlan[] + readingProgress UserReadingProgress[] createdPages Page[] @relation("PageCreator") updatedPages Page[] @relation("PageUpdater") uploadedFiles MediaFile[] @@ -500,3 +502,87 @@ enum SubscriptionStatus { INCOMPLETE_EXPIRED UNPAID } + +// Reading Plans +model ReadingPlan { + id String @id @default(uuid()) + name String // "Bible in One Year", "New Testament in 30 Days" + description String? @db.Text + type ReadingPlanType @default(PREDEFINED) + duration Int // Number of days + schedule Json // Daily reading schedule: {day: 1, readings: [{book, chapter, verses}]} + difficulty String @default("beginner") // beginner, intermediate, advanced + language String @default("en") + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userPlans UserReadingPlan[] + + @@index([type]) + @@index([language]) + @@index([isActive]) +} + +enum ReadingPlanType { + PREDEFINED + CUSTOM +} + +model UserReadingPlan { + id String @id @default(uuid()) + userId String + planId String? // Null for custom plans + name String // Plan name (especially for custom plans) + startDate DateTime @default(now()) + targetEndDate DateTime // Expected completion date + actualEndDate DateTime? // When actually completed + status ReadingPlanStatus @default(ACTIVE) + currentDay Int @default(1) // Current day in plan + completedDays Int @default(0) // Total days completed + streak Int @default(0) // Current consecutive days + longestStreak Int @default(0) // Best streak achieved + customSchedule Json? // For custom plans: same format as ReadingPlan.schedule + reminderEnabled Boolean @default(true) + reminderTime String? // "08:00" - time of day for reminder + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + plan ReadingPlan? @relation(fields: [planId], references: [id], onDelete: SetNull) + progress UserReadingProgress[] + + @@index([userId]) + @@index([status]) + @@index([userId, status]) +} + +enum ReadingPlanStatus { + ACTIVE + COMPLETED + PAUSED + CANCELLED +} + +model UserReadingProgress { + id String @id @default(uuid()) + userId String + userPlanId String + planDay Int // Day number in the reading plan + date DateTime @default(now()) // Date of reading + bookId String // Bible book read + chapterNum Int + versesRead String? // "1-10" or "all" or null for whole chapter + completed Boolean @default(true) + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userPlan UserReadingPlan @relation(fields: [userPlanId], references: [id], onDelete: Cascade) + + @@unique([userPlanId, planDay, bookId, chapterNum]) // One entry per chapter per day per plan + @@index([userId]) + @@index([userPlanId]) + @@index([userId, date]) +} diff --git a/prisma/seed-reading-plans.ts b/prisma/seed-reading-plans.ts new file mode 100644 index 0000000..83c8392 --- /dev/null +++ b/prisma/seed-reading-plans.ts @@ -0,0 +1,242 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function main() { + console.log('Seeding reading plans...') + + // Bible in One Year (simplified - just a sample schedule) + const bibleInOneYear = await prisma.readingPlan.upsert({ + where: { id: 'bible-one-year-en' }, + update: {}, + create: { + id: 'bible-one-year-en', + name: 'Read the Bible in One Year', + description: 'A complete reading plan to read through the entire Bible in 365 days. This plan includes readings from both Old and New Testament each day.', + type: 'PREDEFINED', + duration: 365, + difficulty: 'intermediate', + language: 'en', + isActive: true, + schedule: generateBibleInOneYearSchedule() + } + }) + + // Read the Bible in 90 Days + const bible90Days = await prisma.readingPlan.upsert({ + where: { id: 'bible-90-days-en' }, + update: {}, + create: { + id: 'bible-90-days-en', + name: 'Read the Bible in 90 Days', + description: 'An intensive reading plan to complete the entire Bible in just 90 days. Requires reading about 12-15 chapters per day.', + type: 'PREDEFINED', + duration: 90, + difficulty: 'advanced', + language: 'en', + isActive: true, + schedule: generateBible90DaysSchedule() + } + }) + + // New Testament in 30 Days + const newTestament30 = await prisma.readingPlan.upsert({ + where: { id: 'new-testament-30-en' }, + update: {}, + create: { + id: 'new-testament-30-en', + name: 'New Testament in 30 Days', + description: 'Read through the entire New Testament in one month. Perfect for focusing on the life and teachings of Jesus and the early church.', + type: 'PREDEFINED', + duration: 30, + difficulty: 'intermediate', + language: 'en', + isActive: true, + schedule: generateNewTestament30Schedule() + } + }) + + // Psalms in 30 Days + const psalms30 = await prisma.readingPlan.upsert({ + where: { id: 'psalms-30-en' }, + update: {}, + create: { + id: 'psalms-30-en', + name: 'Psalms in 30 Days', + description: 'Read through all 150 Psalms in 30 days. Experience the full range of worship, prayer, and praise in the book of Psalms.', + type: 'PREDEFINED', + duration: 30, + difficulty: 'beginner', + language: 'en', + isActive: true, + schedule: generatePsalms30Schedule() + } + }) + + // Gospels in 30 Days + const gospels30 = await prisma.readingPlan.upsert({ + where: { id: 'gospels-30-en' }, + update: {}, + create: { + id: 'gospels-30-en', + name: 'The Four Gospels in 30 Days', + description: 'Read through Matthew, Mark, Luke, and John in one month. Focus on the life, teachings, death, and resurrection of Jesus Christ.', + type: 'PREDEFINED', + duration: 30, + difficulty: 'beginner', + language: 'en', + isActive: true, + schedule: generateGospels30Schedule() + } + }) + + console.log('✅ Reading plans seeded successfully!') + console.log('Created plans:') + console.log('- Bible in One Year') + console.log('- Bible in 90 Days') + console.log('- New Testament in 30 Days') + console.log('- Psalms in 30 Days') + console.log('- The Four Gospels in 30 Days') +} + +// Helper functions to generate schedules +function generateBibleInOneYearSchedule() { + const schedule = [] + // Simplified: Just create a sample schedule with some books + // In production, this would be a complete 365-day schedule + for (let day = 1; day <= 365; day++) { + schedule.push({ + day, + readings: [ + { book: 'Genesis', chapter: day % 50 + 1 }, + { book: 'Matthew', chapter: ((day - 1) % 28) + 1 } + ] + }) + } + return schedule +} + +function generateBible90DaysSchedule() { + const schedule = [] + // Simplified schedule for 90 days + for (let day = 1; day <= 90; day++) { + schedule.push({ + day, + readings: [ + { book: 'Genesis', chapter: (day * 2) % 50 + 1 }, + { book: 'Exodus', chapter: (day * 2 + 1) % 40 + 1 }, + { book: 'Matthew', chapter: ((day - 1) % 28) + 1 } + ] + }) + } + return schedule +} + +function generateNewTestament30Schedule() { + const books = [ + { name: 'Matthew', chapters: 28 }, + { name: 'Mark', chapters: 16 }, + { name: 'Luke', chapters: 24 }, + { name: 'John', chapters: 21 }, + { name: 'Acts', chapters: 28 }, + { name: 'Romans', chapters: 16 }, + { name: 'Corinthians_1', chapters: 16 }, + { name: 'Corinthians_2', chapters: 13 } + ] + + const schedule = [] + let currentBook = 0 + let currentChapter = 1 + + for (let day = 1; day <= 30; day++) { + const dayReadings = [] + + // Add approximately 9 chapters per day (260 chapters / 30 days) + for (let i = 0; i < 9; i++) { + if (currentBook < books.length) { + dayReadings.push({ + book: books[currentBook].name, + chapter: currentChapter + }) + + currentChapter++ + if (currentChapter > books[currentBook].chapters) { + currentBook++ + currentChapter = 1 + } + } + } + + schedule.push({ day, readings: dayReadings }) + } + + return schedule +} + +function generatePsalms30Schedule() { + const schedule = [] + let psalm = 1 + + for (let day = 1; day <= 30; day++) { + const dayReadings = [] + + // Read 5 Psalms per day (150 / 30 = 5) + for (let i = 0; i < 5 && psalm <= 150; i++) { + dayReadings.push({ + book: 'Psalms', + chapter: psalm + }) + psalm++ + } + + schedule.push({ day, readings: dayReadings }) + } + + return schedule +} + +function generateGospels30Schedule() { + const gospels = [ + { name: 'Matthew', chapters: 28 }, + { name: 'Mark', chapters: 16 }, + { name: 'Luke', chapters: 24 }, + { name: 'John', chapters: 21 } + ] + + const schedule = [] + let currentGospel = 0 + let currentChapter = 1 + + for (let day = 1; day <= 30; day++) { + const dayReadings = [] + + // Read about 3 chapters per day (89 total chapters / 30 days ≈ 3) + for (let i = 0; i < 3; i++) { + if (currentGospel < gospels.length) { + dayReadings.push({ + book: gospels[currentGospel].name, + chapter: currentChapter + }) + + currentChapter++ + if (currentChapter > gospels[currentGospel].chapters) { + currentGospel++ + currentChapter = 1 + } + } + } + + schedule.push({ day, readings: dayReadings }) + } + + return schedule +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + })