feat: add user settings save and reading plans with progress tracking
User Settings: - Add /api/user/settings endpoint for persisting theme and fontSize preferences - Update settings page with working save functionality - Add validation and localized error messages Reading Plans: - Add database schema with ReadingPlan, UserReadingPlan, and UserReadingProgress models - Create CRUD API endpoints for reading plans and progress tracking - Build UI for browsing available plans and managing user enrollments - Implement progress tracking with daily reading schedule - Add streak calculation and statistics display - Create seed data with 5 predefined plans (Bible in 1 year, 90 days, NT 30 days, Psalms 30, Gospels 30) - Add navigation link with internationalization support Technical: - Update to MUI v7 Grid API (using size prop instead of xs/sm/md and removing item prop) - Fix Next.js 15 dynamic route params (await params pattern) - Add translations for readingPlans in all languages (en, ro, es, it) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
469
app/[locale]/reading-plans/[id]/page.tsx
Normal file
469
app/[locale]/reading-plans/[id]/page.tsx
Normal file
@@ -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<UserPlan | null>(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 (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plan) {
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Alert severity="error">Reading plan not found</Alert>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
href={`/${locale}/reading-plans`}
|
||||||
|
startIcon={<ArrowBack />}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
Back to Reading Plans
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box mb={4}>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
href={`/${locale}/reading-plans`}
|
||||||
|
startIcon={<ArrowBack />}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
Back to Reading Plans
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="start" mb={2}>
|
||||||
|
<Typography variant="h4" fontWeight="700">
|
||||||
|
{plan.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip label={plan.status} color="primary" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{plan.plan?.description && (
|
||||||
|
<Typography variant="body1" color="text.secondary" paragraph>
|
||||||
|
{plan.plan.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={1}>
|
||||||
|
<TrendingUp sx={{ mr: 1, color: 'primary.main' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Progress
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" fontWeight="700">
|
||||||
|
{Math.round(progressPercentage)}%
|
||||||
|
</Typography>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={progressPercentage}
|
||||||
|
sx={{ mt: 1, height: 6, borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{plan.completedDays} / {duration} days
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={1}>
|
||||||
|
<LocalFireDepartment sx={{ mr: 1, color: 'error.main' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Current Streak
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" fontWeight="700" color="error.main">
|
||||||
|
{plan.streak}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
days in a row
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={1}>
|
||||||
|
<EmojiEvents sx={{ mr: 1, color: 'warning.main' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Best Streak
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" fontWeight="700" color="warning.main">
|
||||||
|
{plan.longestStreak}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
days record
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" mb={1}>
|
||||||
|
<CalendarToday sx={{ mr: 1, color: 'success.main' }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Target Date
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body1" fontWeight="600">
|
||||||
|
{new Date(plan.targetEndDate).toLocaleDateString(locale, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Reading Schedule */}
|
||||||
|
<Paper elevation={2} sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom fontWeight="600">
|
||||||
|
Reading Schedule
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{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 (
|
||||||
|
<ListItem
|
||||||
|
key={day}
|
||||||
|
sx={{
|
||||||
|
bgcolor: isCurrent ? 'primary.light' : isCompleted ? 'success.light' : 'inherit',
|
||||||
|
borderRadius: 1,
|
||||||
|
mb: 1,
|
||||||
|
opacity: isCompleted ? 0.8 : 1
|
||||||
|
}}
|
||||||
|
secondaryAction={
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => setNotesDialog({ open: true, day, notes })}
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</IconButton>
|
||||||
|
<Checkbox
|
||||||
|
checked={isCompleted}
|
||||||
|
onChange={() => {
|
||||||
|
if (!isCompleted && daySchedule.readings && daySchedule.readings.length > 0) {
|
||||||
|
markDayComplete(day, daySchedule.readings[0])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
icon={<RadioButtonUnchecked />}
|
||||||
|
checkedIcon={<CheckCircle color="success" />}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Chip
|
||||||
|
label={`Day ${day}`}
|
||||||
|
size="small"
|
||||||
|
color={isCurrent ? 'primary' : 'default'}
|
||||||
|
/>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box>
|
||||||
|
{daySchedule.readings?.map((reading: any, i: number) => (
|
||||||
|
<Typography key={i} variant="body1" component="span">
|
||||||
|
{reading.book} {reading.chapter}
|
||||||
|
{reading.verses && `:${reading.verses}`}
|
||||||
|
{i < daySchedule.readings.length - 1 && ', '}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={notes && `Notes: ${notes}`}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{(!schedule || !Array.isArray(schedule)) && (
|
||||||
|
<Typography color="text.secondary" textAlign="center" py={2}>
|
||||||
|
No schedule available for this plan
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Notes Dialog */}
|
||||||
|
<Dialog open={notesDialog.open} onClose={() => setNotesDialog({ open: false, day: 0, notes: '' })} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Add Notes - Day {notesDialog.day}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={notesDialog.notes}
|
||||||
|
onChange={(e) => setNotesDialog({ ...notesDialog, notes: e.target.value })}
|
||||||
|
placeholder="Add your thoughts, insights, or reflections..."
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setNotesDialog({ open: false, day: 0, notes: '' })}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<Save />} onClick={saveNotes}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
437
app/[locale]/reading-plans/page.tsx
Normal file
437
app/[locale]/reading-plans/page.tsx
Normal file
@@ -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<ReadingPlan[]>([])
|
||||||
|
const [userPlans, setUserPlans] = useState<UserPlan[]>([])
|
||||||
|
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 (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box textAlign="center" mb={4}>
|
||||||
|
<MenuBook sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom fontWeight="700">
|
||||||
|
Reading Plans
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Stay consistent in your Bible reading with structured reading plans
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
|
<Tabs value={tabValue} onChange={(e, v) => setTabValue(v)}>
|
||||||
|
<Tab label="Available Plans" />
|
||||||
|
<Tab label="My Plans" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Available Plans Tab */}
|
||||||
|
{tabValue === 0 && (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{availablePlans.map((plan) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={plan.id}>
|
||||||
|
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
<Typography variant="h6" gutterBottom fontWeight="600">
|
||||||
|
{plan.name}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||||
|
<Chip
|
||||||
|
label={`${plan.duration} days`}
|
||||||
|
size="small"
|
||||||
|
icon={<CalendarToday />}
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={plan.difficulty}
|
||||||
|
size="small"
|
||||||
|
color={getDifficultyColor(plan.difficulty) as any}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{plan.description}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<PlayArrow />}
|
||||||
|
onClick={() => enrollInPlan(plan.id)}
|
||||||
|
>
|
||||||
|
Start Plan
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{availablePlans.length === 0 && (
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box textAlign="center" py={4}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No reading plans available for this language yet.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* My Plans Tab */}
|
||||||
|
{tabValue === 1 && (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{userPlans.map((userPlan) => {
|
||||||
|
const progress = userPlan.completedDays / (userPlan.plan?.duration || 365) * 100
|
||||||
|
return (
|
||||||
|
<Grid size={{ xs: 12, md: 6 }} key={userPlan.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="start" mb={2}>
|
||||||
|
<Typography variant="h6" fontWeight="600">
|
||||||
|
{userPlan.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={userPlan.status}
|
||||||
|
size="small"
|
||||||
|
color={getStatusColor(userPlan.status) as any}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box display="flex" justifyContent="space-between" mb={1}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Progress
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight="600">
|
||||||
|
{userPlan.completedDays} / {userPlan.plan?.duration || 365} days
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={Math.min(progress, 100)}
|
||||||
|
sx={{ height: 8, borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Current Streak
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="primary.main">
|
||||||
|
{userPlan.streak} days
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Best Streak
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="success.main">
|
||||||
|
{userPlan.longestStreak} days
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
component={Link}
|
||||||
|
href={`/${locale}/reading-plans/${userPlan.id}`}
|
||||||
|
startIcon={<TrendingUp />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{userPlan.status === 'ACTIVE' && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => updatePlanStatus(userPlan.id, 'PAUSED')}
|
||||||
|
title="Pause"
|
||||||
|
>
|
||||||
|
<Pause />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userPlan.status === 'PAUSED' && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => updatePlanStatus(userPlan.id, 'ACTIVE')}
|
||||||
|
title="Resume"
|
||||||
|
>
|
||||||
|
<PlayArrow />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{userPlan.status !== 'COMPLETED' && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => deletePlan(userPlan.id)}
|
||||||
|
title="Delete"
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{userPlans.length === 0 && (
|
||||||
|
<Grid size={{ xs: 12 }}>
|
||||||
|
<Box textAlign="center" py={4}>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
You haven't enrolled in any reading plans yet.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
|
onClick={() => setTabValue(0)}
|
||||||
|
>
|
||||||
|
Browse Available Plans
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</ProtectedRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -126,11 +126,37 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
const token = localStorage.getItem('authToken')
|
||||||
|
if (!token) {
|
||||||
|
setMessage(t('settingsError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Implement settings update API
|
const response = await fetch(`/api/user/settings?locale=${locale}`, {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000)) // Placeholder
|
method: 'PUT',
|
||||||
setMessage(t('settingsSaved'))
|
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) {
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error)
|
||||||
setMessage(t('settingsError'))
|
setMessage(t('settingsError'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/api/reading-plans/route.ts
Normal file
48
app/api/reading-plans/route.ts
Normal file
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
234
app/api/user/reading-plans/[id]/progress/route.ts
Normal file
234
app/api/user/reading-plans/[id]/progress/route.ts
Normal file
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
230
app/api/user/reading-plans/[id]/route.ts
Normal file
230
app/api/user/reading-plans/[id]/route.ts
Normal file
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/api/user/reading-plans/route.ts
Normal file
181
app/api/user/reading-plans/route.ts
Normal file
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/api/user/settings/route.ts
Normal file
103
app/api/user/settings/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
Logout,
|
Logout,
|
||||||
Login,
|
Login,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
|
CalendarToday,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
import { useTranslations, useLocale } from 'next-intl'
|
||||||
@@ -92,6 +93,7 @@ export function Navigation() {
|
|||||||
const authenticatedPages = [
|
const authenticatedPages = [
|
||||||
...pages,
|
...pages,
|
||||||
{ name: t('bookmarks'), path: '/bookmarks', icon: <Bookmark /> },
|
{ name: t('bookmarks'), path: '/bookmarks', icon: <Bookmark /> },
|
||||||
|
{ name: t('readingPlans'), path: '/reading-plans', icon: <CalendarToday /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
const settings = [
|
const settings = [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"prayers": "Prayers",
|
"prayers": "Prayers",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"bookmarks": "Bookmarks",
|
"bookmarks": "Bookmarks",
|
||||||
|
"readingPlans": "Reading Plans",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"prayers": "Oraciones",
|
"prayers": "Oraciones",
|
||||||
"search": "Buscar",
|
"search": "Buscar",
|
||||||
"bookmarks": "Marcadores",
|
"bookmarks": "Marcadores",
|
||||||
|
"readingPlans": "Planes de Lectura",
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"logout": "Cerrar sesión",
|
"logout": "Cerrar sesión",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"prayers": "Preghiere",
|
"prayers": "Preghiere",
|
||||||
"search": "Cerca",
|
"search": "Cerca",
|
||||||
"bookmarks": "Segnalibri",
|
"bookmarks": "Segnalibri",
|
||||||
|
"readingPlans": "Piani di Lettura",
|
||||||
"profile": "Profilo",
|
"profile": "Profilo",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
"logout": "Disconnetti",
|
"logout": "Disconnetti",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"prayers": "Rugăciuni",
|
"prayers": "Rugăciuni",
|
||||||
"search": "Căutare",
|
"search": "Căutare",
|
||||||
"bookmarks": "Favorite",
|
"bookmarks": "Favorite",
|
||||||
|
"readingPlans": "Planuri de Lectură",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"settings": "Setări",
|
"settings": "Setări",
|
||||||
"logout": "Deconectare",
|
"logout": "Deconectare",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ model User {
|
|||||||
userPrayers UserPrayer[]
|
userPrayers UserPrayer[]
|
||||||
readingHistory ReadingHistory[]
|
readingHistory ReadingHistory[]
|
||||||
preferences UserPreference[]
|
preferences UserPreference[]
|
||||||
|
userReadingPlans UserReadingPlan[]
|
||||||
|
readingProgress UserReadingProgress[]
|
||||||
createdPages Page[] @relation("PageCreator")
|
createdPages Page[] @relation("PageCreator")
|
||||||
updatedPages Page[] @relation("PageUpdater")
|
updatedPages Page[] @relation("PageUpdater")
|
||||||
uploadedFiles MediaFile[]
|
uploadedFiles MediaFile[]
|
||||||
@@ -500,3 +502,87 @@ enum SubscriptionStatus {
|
|||||||
INCOMPLETE_EXPIRED
|
INCOMPLETE_EXPIRED
|
||||||
UNPAID
|
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])
|
||||||
|
}
|
||||||
|
|||||||
242
prisma/seed-reading-plans.ts
Normal file
242
prisma/seed-reading-plans.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user