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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user