Fixed empty "Resets on" date display for new users: Issue: - Users who haven't created any conversations yet have limitResetDate = NULL - The "Resets on" field was showing empty/blank - This confused users about when their limit would reset Solution: - Updated formatResetDate() in 3 components to calculate default date - If limitResetDate is NULL, display "1 month from now" - This gives users a clear expectation of when limits reset Files Updated: - app/[locale]/subscription/page.tsx * formatResetDate() now returns calculated date if null - components/subscription/usage-display.tsx * formatResetDate() now returns calculated date if null - components/subscription/upgrade-modal.tsx * formatResetDate() now returns calculated date if null * Removed conditional check - always show reset date User Experience: - New users see "Resets on: [date one month from now]" - Once they create their first conversation, actual reset date is set - Consistent messaging across all subscription UI components Note: The actual limitResetDate is set when the user creates their first conversation (in incrementConversationCount function). This fix only affects the UI display for users who haven't chatted yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
412 lines
13 KiB
TypeScript
412 lines
13 KiB
TypeScript
'use client'
|
|
import { useState, useEffect } from 'react'
|
|
import {
|
|
Container,
|
|
Box,
|
|
Typography,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
Paper,
|
|
LinearProgress,
|
|
Chip,
|
|
CircularProgress,
|
|
Alert,
|
|
Switch,
|
|
FormControlLabel,
|
|
Divider
|
|
} from '@mui/material'
|
|
import {
|
|
CheckCircle,
|
|
Favorite,
|
|
TrendingUp,
|
|
Settings as SettingsIcon
|
|
} from '@mui/icons-material'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTranslations, useLocale } from 'next-intl'
|
|
|
|
interface UserSubscriptionData {
|
|
tier: string
|
|
status: string
|
|
conversationLimit: number
|
|
conversationCount: number
|
|
limitResetDate: string | null
|
|
}
|
|
|
|
const STRIPE_PRICES = {
|
|
monthly: process.env.NEXT_PUBLIC_STRIPE_PREMIUM_MONTHLY_PRICE_ID || '',
|
|
yearly: process.env.NEXT_PUBLIC_STRIPE_PREMIUM_YEARLY_PRICE_ID || ''
|
|
}
|
|
|
|
export default function SubscriptionPage() {
|
|
const router = useRouter()
|
|
const locale = useLocale()
|
|
const t = useTranslations('subscription')
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
const [processing, setProcessing] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [userData, setUserData] = useState<UserSubscriptionData | null>(null)
|
|
const [billingInterval, setBillingInterval] = useState<'month' | 'year'>('month')
|
|
|
|
useEffect(() => {
|
|
fetchUserData()
|
|
}, [])
|
|
|
|
const fetchUserData = async () => {
|
|
try {
|
|
const token = localStorage.getItem('authToken')
|
|
if (!token) {
|
|
router.push(`/${locale}/login`)
|
|
return
|
|
}
|
|
|
|
const response = await fetch('/api/user/profile', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
})
|
|
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
setUserData({
|
|
tier: data.user.subscriptionTier || 'free',
|
|
status: data.user.subscriptionStatus || 'active',
|
|
conversationLimit: data.user.conversationLimit || 10,
|
|
conversationCount: data.user.conversationCount || 0,
|
|
limitResetDate: data.user.limitResetDate
|
|
})
|
|
} else {
|
|
setError(t('errors.loadFailed'))
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching user data:', err)
|
|
setError(t('errors.generic'))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleUpgrade = async () => {
|
|
setProcessing(true)
|
|
setError('')
|
|
|
|
try {
|
|
const token = localStorage.getItem('authToken')
|
|
if (!token) {
|
|
router.push(`/${locale}/login`)
|
|
return
|
|
}
|
|
|
|
const priceId = billingInterval === 'month' ? STRIPE_PRICES.monthly : STRIPE_PRICES.yearly
|
|
|
|
const response = await fetch('/api/subscriptions/checkout', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
priceId,
|
|
interval: billingInterval,
|
|
locale
|
|
})
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success && data.url) {
|
|
window.location.href = data.url
|
|
} else {
|
|
setError(data.error || t('errors.checkoutFailed'))
|
|
}
|
|
} catch (err) {
|
|
console.error('Error creating checkout:', err)
|
|
setError(t('errors.generic'))
|
|
} finally {
|
|
setProcessing(false)
|
|
}
|
|
}
|
|
|
|
const handleManageSubscription = async () => {
|
|
setProcessing(true)
|
|
setError('')
|
|
|
|
try {
|
|
const token = localStorage.getItem('authToken')
|
|
if (!token) {
|
|
router.push(`/${locale}/login`)
|
|
return
|
|
}
|
|
|
|
const response = await fetch('/api/subscriptions/portal', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ locale })
|
|
})
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success && data.url) {
|
|
window.location.href = data.url
|
|
} else {
|
|
setError(data.error || t('errors.portalFailed'))
|
|
}
|
|
} catch (err) {
|
|
console.error('Error opening portal:', err)
|
|
setError(t('errors.generic'))
|
|
} finally {
|
|
setProcessing(false)
|
|
}
|
|
}
|
|
|
|
const formatResetDate = (dateString: string | null) => {
|
|
if (!dateString) {
|
|
// If no reset date set, calculate 1 month from now
|
|
const nextMonth = new Date()
|
|
nextMonth.setMonth(nextMonth.getMonth() + 1)
|
|
return nextMonth.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })
|
|
}
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })
|
|
}
|
|
|
|
const isPremium = userData?.tier === 'premium'
|
|
const usagePercentage = userData ? (userData.conversationCount / userData.conversationLimit) * 100 : 0
|
|
const remaining = userData ? Math.max(0, userData.conversationLimit - userData.conversationCount) : 0
|
|
|
|
if (loading) {
|
|
return (
|
|
<Container maxWidth="lg" sx={{ py: 8, display: 'flex', justifyContent: 'center' }}>
|
|
<CircularProgress />
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ py: 8 }}>
|
|
{/* Header */}
|
|
<Box sx={{ mb: 6, textAlign: 'center' }}>
|
|
<Typography variant="h3" component="h1" gutterBottom>
|
|
{t('title')}
|
|
</Typography>
|
|
<Typography variant="body1" color="text.secondary">
|
|
{t('subtitle')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 4 }} onClose={() => setError('')}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Current Plan & Usage */}
|
|
{userData && (
|
|
<Paper elevation={2} sx={{ p: 4, mb: 6 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
|
<Box>
|
|
<Typography variant="h5" gutterBottom>
|
|
{t('currentPlan')}
|
|
</Typography>
|
|
<Chip
|
|
label={isPremium ? t('premium.name') : t('free.name')}
|
|
color={isPremium ? 'primary' : 'default'}
|
|
sx={{ fontWeight: 600 }}
|
|
/>
|
|
{isPremium && (
|
|
<Chip
|
|
label={t(`status.${userData.status}`)}
|
|
color={userData.status === 'active' ? 'success' : 'warning'}
|
|
size="small"
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
{isPremium && (
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<SettingsIcon />}
|
|
onClick={handleManageSubscription}
|
|
disabled={processing}
|
|
>
|
|
{t('managePlan')}
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
|
|
<Divider sx={{ my: 3 }} />
|
|
|
|
{/* Usage Statistics */}
|
|
<Box>
|
|
<Typography variant="h6" gutterBottom>
|
|
{t('usage.title')}
|
|
</Typography>
|
|
<Box sx={{ mb: 2 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
|
<Typography variant="body2">
|
|
{t('usage.conversations')}
|
|
</Typography>
|
|
<Typography variant="body2" fontWeight="600">
|
|
{isPremium ? (
|
|
t('usage.unlimited')
|
|
) : (
|
|
`${userData.conversationCount} ${t('usage.of')} ${userData.conversationLimit}`
|
|
)}
|
|
</Typography>
|
|
</Box>
|
|
{!isPremium && (
|
|
<>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={Math.min(usagePercentage, 100)}
|
|
sx={{ height: 8, borderRadius: 1 }}
|
|
/>
|
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
|
{remaining} {t('usage.remaining')} • {t('usage.resetsOn')} {formatResetDate(userData.limitResetDate)}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* Billing Interval Toggle */}
|
|
{!isPremium && (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 4 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={billingInterval === 'year'}
|
|
onChange={(e) => setBillingInterval(e.target.checked ? 'year' : 'month')}
|
|
/>
|
|
}
|
|
label={
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography>{t('billing.yearly')}</Typography>
|
|
<Chip label={t('premium.savings')} size="small" color="success" />
|
|
</Box>
|
|
}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Plan Comparison */}
|
|
<Box sx={{ display: 'flex', gap: 4, flexWrap: 'wrap', justifyContent: 'center' }}>
|
|
{/* Free Plan */}
|
|
<Card sx={{ flex: { xs: '1 1 100%', md: '1 1 400px' }, maxWidth: 450 }}>
|
|
<CardContent sx={{ p: 4 }}>
|
|
<Typography variant="h5" gutterBottom fontWeight="600">
|
|
{t('free.name')}
|
|
</Typography>
|
|
<Box sx={{ my: 3 }}>
|
|
<Typography variant="h3" component="div" fontWeight="700">
|
|
{t('free.price')}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{t('free.period')}
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
{t('free.description')}
|
|
</Typography>
|
|
<Box sx={{ mb: 3 }}>
|
|
{[
|
|
t('free.features.conversations'),
|
|
t('free.features.bible'),
|
|
t('free.features.prayer'),
|
|
t('free.features.bookmarks')
|
|
].map((feature, index) => (
|
|
<Box key={index} sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
|
<CheckCircle color="success" fontSize="small" />
|
|
<Typography variant="body2">{feature}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
fullWidth
|
|
disabled
|
|
sx={{ py: 1.5 }}
|
|
>
|
|
{t('free.cta')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Premium Plan */}
|
|
<Card
|
|
sx={{
|
|
flex: { xs: '1 1 100%', md: '1 1 400px' },
|
|
maxWidth: 450,
|
|
border: 2,
|
|
borderColor: 'primary.main',
|
|
position: 'relative'
|
|
}}
|
|
>
|
|
{!isPremium && (
|
|
<Chip
|
|
label="Recommended"
|
|
color="primary"
|
|
size="small"
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 16,
|
|
right: 16,
|
|
fontWeight: 600
|
|
}}
|
|
/>
|
|
)}
|
|
<CardContent sx={{ p: 4 }}>
|
|
<Typography variant="h5" gutterBottom fontWeight="600">
|
|
{t('premium.name')}
|
|
</Typography>
|
|
<Box sx={{ my: 3 }}>
|
|
<Typography variant="h3" component="div" fontWeight="700">
|
|
{billingInterval === 'month' ? t('premium.priceMonthly') : t('premium.priceYearly')}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{billingInterval === 'month' ? t('premium.periodMonthly') : t('premium.periodYearly')}
|
|
</Typography>
|
|
</Box>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
{t('premium.description')}
|
|
</Typography>
|
|
<Box sx={{ mb: 3 }}>
|
|
{[
|
|
t('premium.features.conversations'),
|
|
t('premium.features.bible'),
|
|
t('premium.features.prayer'),
|
|
t('premium.features.bookmarks'),
|
|
t('premium.features.support'),
|
|
t('premium.features.early')
|
|
].map((feature, index) => (
|
|
<Box key={index} sx={{ display: 'flex', gap: 1, mb: 1.5 }}>
|
|
<CheckCircle color="primary" fontSize="small" />
|
|
<Typography variant="body2">{feature}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
<Button
|
|
variant="contained"
|
|
fullWidth
|
|
size="large"
|
|
startIcon={isPremium ? <TrendingUp /> : <Favorite />}
|
|
onClick={isPremium ? handleManageSubscription : handleUpgrade}
|
|
disabled={processing}
|
|
sx={{ py: 1.5 }}
|
|
>
|
|
{processing ? t('premium.ctaProcessing') : isPremium ? t('managePlan') : t('premium.cta')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</Box>
|
|
</Container>
|
|
)
|
|
}
|