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>
438 lines
13 KiB
TypeScript
438 lines
13 KiB
TypeScript
'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>
|
|
)
|
|
}
|