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:
2025-10-12 23:07:47 +00:00
parent 9d82e719ed
commit 63082c825a
15 changed files with 2065 additions and 3 deletions

View 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>
)
}

View 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>
)
}

View File

@@ -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'))
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 })
}
}