feat: implement subscription system with conversation limits
Implement complete backend subscription system that limits free users to 10 AI conversations per month and offers Premium tier ($10/month or $100/year) with unlimited conversations. Changes: - Add User subscription fields (tier, status, limits, counters) - Create Subscription model to track Stripe subscriptions - Implement conversation limit enforcement in chat API - Add subscription checkout and customer portal APIs - Update Stripe webhook to handle subscription events - Add subscription utility functions (limit checks, tier management) - Add comprehensive subscription translations (en, ro, es, it) - Update environment variables for Stripe price IDs - Update footer "Sponsor Us" link to point to /donate - Add "Sponsor Us" button to home page hero section Database: - User model: subscriptionTier, subscriptionStatus, conversationLimit, conversationCount, limitResetDate, stripeCustomerId, stripeSubscriptionId - Subscription model: tracks Stripe subscription details, periods, status - SubscriptionStatus enum: ACTIVE, CANCELLED, PAST_DUE, TRIALING, etc. API Routes: - POST /api/subscriptions/checkout - Create Stripe checkout session - POST /api/subscriptions/portal - Get customer portal link - Webhook handlers for: customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed Features: - Free tier: 10 conversations/month with automatic monthly reset - Premium tier: Unlimited conversations - Automatic limit enforcement before conversation creation - Returns LIMIT_REACHED error with upgrade URL when limit hit - Stripe Customer Portal integration for subscription management - Automatic tier upgrade/downgrade via webhooks Documentation: - SUBSCRIPTION_IMPLEMENTATION_PLAN.md - Complete implementation plan - SUBSCRIPTION_IMPLEMENTATION_STATUS.md - Current status and next steps Frontend UI still needed: subscription page, upgrade modal, usage display 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,48 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
TextField,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
useTheme,
|
||||
Divider,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
TextField,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
MenuBook,
|
||||
Chat,
|
||||
Favorite,
|
||||
CheckCircle,
|
||||
Public,
|
||||
Search,
|
||||
Language,
|
||||
CloudOff,
|
||||
Security,
|
||||
AutoStories,
|
||||
Public,
|
||||
VolunteerActivism,
|
||||
CheckCircle,
|
||||
} from '@mui/icons-material'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { DONATION_PRESETS } from '@/lib/stripe'
|
||||
|
||||
export default function DonatePage() {
|
||||
const theme = useTheme()
|
||||
const router = useRouter()
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('donate')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
@@ -74,12 +83,12 @@ export default function DonatePage() {
|
||||
|
||||
// Validation
|
||||
if (!amount || amount < 1) {
|
||||
setError('Please enter a valid amount (minimum $1)')
|
||||
setError(t('form.errors.invalidAmount'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
setError('Please enter a valid email address')
|
||||
setError(t('form.errors.invalidEmail'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,7 +116,7 @@ export default function DonatePage() {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to create checkout session')
|
||||
throw new Error(data.error || t('form.errors.checkoutFailed'))
|
||||
}
|
||||
|
||||
// Redirect to Stripe Checkout
|
||||
@@ -116,66 +125,349 @@ export default function DonatePage() {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Donation error:', err)
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
setError(err instanceof Error ? err.message : t('form.errors.generic'))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Public />,
|
||||
text: '1,200+ Bible versions in multiple languages',
|
||||
icon: <Public sx={{ fontSize: 48 }} />,
|
||||
title: t('features.globalLibrary.title'),
|
||||
description: t('features.globalLibrary.description'),
|
||||
},
|
||||
{
|
||||
icon: <Language />,
|
||||
text: 'Multilingual access for believers worldwide',
|
||||
icon: <Language sx={{ fontSize: 48 }} />,
|
||||
title: t('features.multilingual.title'),
|
||||
description: t('features.multilingual.description'),
|
||||
},
|
||||
{
|
||||
icon: <CloudOff />,
|
||||
text: 'Offline access to Scripture anywhere',
|
||||
icon: <Favorite sx={{ fontSize: 48 }} />,
|
||||
title: t('features.prayerWall.title'),
|
||||
description: t('features.prayerWall.description'),
|
||||
},
|
||||
{
|
||||
icon: <Security />,
|
||||
text: 'Complete privacy - no ads or tracking',
|
||||
icon: <Chat sx={{ fontSize: 48 }} />,
|
||||
title: t('features.aiChat.title'),
|
||||
description: t('features.aiChat.description'),
|
||||
},
|
||||
{
|
||||
icon: <Security sx={{ fontSize: 48 }} />,
|
||||
title: t('features.privacy.title'),
|
||||
description: t('features.privacy.description'),
|
||||
},
|
||||
{
|
||||
icon: <CloudOff sx={{ fontSize: 48 }} />,
|
||||
title: t('features.offline.title'),
|
||||
description: t('features.offline.description'),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ bgcolor: 'grey.50', minHeight: '100vh', py: 8 }}>
|
||||
<Container maxWidth="lg">
|
||||
{/* Hero Section */}
|
||||
<Box sx={{ textAlign: 'center', mb: 8 }}>
|
||||
<Favorite sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Box>
|
||||
{/* Hero Section */}
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #009688 0%, #00796B 100%)',
|
||||
color: 'white',
|
||||
py: 6.25,
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontSize: { xs: '2.5rem', sm: '3.5rem', md: '4.5rem' },
|
||||
fontWeight: 700,
|
||||
mb: 3,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{t('hero.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontSize: { xs: '1.25rem', sm: '1.75rem', md: '2rem' },
|
||||
fontWeight: 400,
|
||||
mb: 6,
|
||||
opacity: 0.95,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{t('hero.subtitle')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
color: 'primary.main',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
'&:hover': { bgcolor: 'grey.100' },
|
||||
textTransform: 'none',
|
||||
}}
|
||||
startIcon={<AutoStories />}
|
||||
onClick={() => router.push(`/${locale}/bible`)}
|
||||
>
|
||||
{t('hero.cta.readBible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
sx={{
|
||||
borderColor: 'white',
|
||||
color: 'white',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
bgcolor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => window.scrollTo({ top: document.getElementById('donate-form')?.offsetTop || 0, behavior: 'smooth' })}
|
||||
>
|
||||
{t('hero.cta.supportMission')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Mission Section */}
|
||||
<Container maxWidth="md" sx={{ pt: { xs: 10, md: 16 }, pb: 0, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
mb: 4,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{t('mission.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.6,
|
||||
color: 'text.secondary',
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{t('mission.description1')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.6,
|
||||
mt: 3,
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{t('mission.different')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.6,
|
||||
mt: 2,
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{t('mission.description2')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.6,
|
||||
mt: 2,
|
||||
color: 'text.secondary',
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{t('mission.description3')}
|
||||
</Typography>
|
||||
</Container>
|
||||
|
||||
<Divider sx={{ maxWidth: 200, mx: 'auto', borderColor: 'grey.300' }} />
|
||||
|
||||
{/* Donation Pitch Section */}
|
||||
<Container maxWidth="md" sx={{ pt: { xs: 10, md: 16 }, pb: 0, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
mb: 4,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{t('pitch.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.8,
|
||||
color: 'text.secondary',
|
||||
mb: 5,
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{t('pitch.description1')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.8,
|
||||
color: 'text.secondary',
|
||||
mb: 5,
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
{t('pitch.description2')}
|
||||
</Typography>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'primary.light',
|
||||
color: 'white',
|
||||
py: 4,
|
||||
px: 3,
|
||||
borderRadius: 3,
|
||||
maxWidth: 600,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.3rem' },
|
||||
fontWeight: 500,
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{t('pitch.verse.text')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 2, fontWeight: 600 }}>
|
||||
{t('pitch.verse.reference')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
{/* Features Section */}
|
||||
<Box sx={{ bgcolor: 'grey.50', pt: { xs: 10, md: 16 }, pb: 0 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
mb: 2,
|
||||
mb: 3,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Support Biblical Guide
|
||||
{t('features.title')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.3rem' },
|
||||
textAlign: 'center',
|
||||
color: 'text.secondary',
|
||||
mb: 8,
|
||||
maxWidth: 700,
|
||||
mx: 'auto',
|
||||
}}
|
||||
>
|
||||
Your donation keeps Scripture free and accessible to everyone, everywhere.
|
||||
{t('features.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 4, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{features.map((feature, index) => (
|
||||
<Box key={index} sx={{ flex: { xs: '1 1 100%', md: '1 1 calc(33.33% - 24px)' }, maxWidth: { xs: '100%', md: 400 } }}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: '100%',
|
||||
textAlign: 'center',
|
||||
bgcolor: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.200',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ color: 'primary.main', mb: 2 }}>
|
||||
{feature.icon}
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ lineHeight: 1.6 }}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Donation Form Section */}
|
||||
<Container id="donate-form" maxWidth="lg" sx={{ pt: { xs: 10, md: 16 }, pb: 0 }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
mb: 6,
|
||||
textAlign: 'center',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{t('form.title')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 4, flexDirection: { xs: 'column', md: 'row' } }}>
|
||||
{/* Donation Form */}
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 58%' } }}>
|
||||
<Paper elevation={2} sx={{ p: 4 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Make a Donation
|
||||
{t('form.makedonation')}
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
@@ -186,7 +478,7 @@ export default function DonatePage() {
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Thank you for your donation!
|
||||
{t('form.success')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -200,7 +492,7 @@ export default function DonatePage() {
|
||||
onChange={(e) => setIsRecurring(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Make this a recurring donation"
|
||||
label={t('form.recurring.label')}
|
||||
/>
|
||||
{isRecurring && (
|
||||
<ToggleButtonGroup
|
||||
@@ -210,10 +502,10 @@ export default function DonatePage() {
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
<ToggleButton value="month" sx={{ flex: 1 }}>
|
||||
Monthly
|
||||
{t('form.recurring.monthly')}
|
||||
</ToggleButton>
|
||||
<ToggleButton value="year" sx={{ flex: 1 }}>
|
||||
Yearly
|
||||
{t('form.recurring.yearly')}
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
@@ -222,7 +514,7 @@ export default function DonatePage() {
|
||||
{/* Amount Selection */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Select Amount (USD)
|
||||
{t('form.amount.label')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2 }}>
|
||||
{DONATION_PRESETS.map((preset) => (
|
||||
@@ -244,7 +536,7 @@ export default function DonatePage() {
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Custom Amount"
|
||||
label={t('form.amount.custom')}
|
||||
type="number"
|
||||
value={customAmount}
|
||||
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||
@@ -260,12 +552,12 @@ export default function DonatePage() {
|
||||
|
||||
{/* Contact Information */}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Your Information
|
||||
{t('form.info.title')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
label={t('form.info.email')}
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
@@ -276,7 +568,7 @@ export default function DonatePage() {
|
||||
{!isAnonymous && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name (optional)"
|
||||
label={t('form.info.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
@@ -290,18 +582,18 @@ export default function DonatePage() {
|
||||
onChange={(e) => setIsAnonymous(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Make this donation anonymous"
|
||||
label={t('form.info.anonymous')}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Message (optional)"
|
||||
label={t('form.info.message')}
|
||||
multiline
|
||||
rows={3}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Share why you're supporting Biblical Guide..."
|
||||
placeholder={t('form.info.messagePlaceholder')}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
@@ -321,7 +613,7 @@ export default function DonatePage() {
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
`Donate ${getAmount() ? `$${getAmount()}` : ''}`
|
||||
`${t('form.submit')} ${getAmount() ? `$${getAmount()}` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -330,9 +622,33 @@ export default function DonatePage() {
|
||||
color="text.secondary"
|
||||
sx={{ mt: 2, textAlign: 'center' }}
|
||||
>
|
||||
Secure payment powered by Stripe
|
||||
{t('form.secure')}
|
||||
</Typography>
|
||||
</form>
|
||||
|
||||
{/* Alternative Donation Methods */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2, textAlign: 'center' }}>
|
||||
{t('alternatives.title')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
fullWidth
|
||||
sx={{ py: 1.5, textTransform: 'none' }}
|
||||
startIcon={<span style={{ fontSize: '1.5rem' }}>💳</span>}
|
||||
href="https://paypal.me/andupetcu"
|
||||
target="_blank"
|
||||
>
|
||||
{t('alternatives.paypal')}
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
<span style={{ fontSize: '1.5rem' }}>🎯</span> {t('alternatives.kickstarter')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -340,19 +656,16 @@ export default function DonatePage() {
|
||||
<Box sx={{ flex: { xs: '1 1 100%', md: '1 1 42%' } }}>
|
||||
<Paper elevation={2} sx={{ p: 4, mb: 3, bgcolor: 'primary.light', color: 'white' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Your Impact
|
||||
{t('impact.title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 3, lineHeight: 1.8 }}>
|
||||
Every donation directly supports the servers, translations, and technology that
|
||||
make Biblical Guide possible.
|
||||
{t('impact.description')}
|
||||
</Typography>
|
||||
<List>
|
||||
{features.map((feature, index) => (
|
||||
{features.slice(0, 4).map((feature, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ListItemIcon sx={{ color: 'white', minWidth: 40 }}>
|
||||
<CheckCircle />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={feature.text} />
|
||||
<CheckCircle sx={{ mr: 2 }} />
|
||||
<ListItemText primary={feature.title} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
@@ -360,36 +673,230 @@ export default function DonatePage() {
|
||||
|
||||
<Paper elevation={2} sx={{ p: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Why Donate?
|
||||
{t('why.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, lineHeight: 1.8 }}>
|
||||
Biblical Guide is committed to keeping God's Word free and accessible to all.
|
||||
We don't have ads, paywalls, or sell your data.
|
||||
{t('why.description1')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.8 }}>
|
||||
When you give, you're not paying for access — you're keeping access open
|
||||
for millions who cannot afford to pay.
|
||||
{t('why.description2')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
bgcolor: 'primary.light',
|
||||
color: 'white',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontStyle: 'italic', textAlign: 'center' }}>
|
||||
Freely you have received; freely give.
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ textAlign: 'center', mt: 1, fontWeight: 600 }}>
|
||||
— Matthew 10:8
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Why It Matters Section */}
|
||||
<Box sx={{ bgcolor: 'grey.900', color: 'white', pt: { xs: 10, md: 16 }, pb: 0 }}>
|
||||
<Container maxWidth="md" sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
mb: 6,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{t('matters.title')}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ maxWidth: 700, mx: 'auto' }}>
|
||||
<ListItem sx={{ py: 2, flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.2rem', mb: 1, lineHeight: 1.6 }}>
|
||||
{t('matters.point1')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
<ListItem sx={{ py: 2, flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.2rem', mb: 1, lineHeight: 1.6 }}>
|
||||
{t('matters.point2')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
<ListItem sx={{ py: 2, flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Typography variant="h6" sx={{ fontSize: '1.2rem', mb: 1, lineHeight: 1.6 }}>
|
||||
{t('matters.point3')}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontSize: { xs: '1.5rem', md: '2rem' },
|
||||
fontWeight: 700,
|
||||
mt: 6,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{t('matters.together')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.3rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.6,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
{t('matters.conclusion')}
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Join the Mission Section */}
|
||||
<Container maxWidth="md" sx={{ pt: { xs: 10, md: 16 }, pb: 0, textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
mb: 4,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{t('join.title')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.8,
|
||||
color: 'text.secondary',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{t('join.description1')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.8,
|
||||
color: 'text.secondary',
|
||||
mb: 6,
|
||||
}}
|
||||
>
|
||||
{t('join.description2')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.8,
|
||||
mb: 6,
|
||||
}}
|
||||
>
|
||||
{t('join.callToAction')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', fontStyle: 'italic' }}>
|
||||
{t('join.closing')}
|
||||
</Typography>
|
||||
</Container>
|
||||
|
||||
{/* Footer CTA */}
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #009688 0%, #00796B 100%)',
|
||||
color: 'white',
|
||||
py: 6.25,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
fontSize: { xs: '1.75rem', md: '2.5rem' },
|
||||
fontWeight: 700,
|
||||
mb: 2,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Biblical-Guide.com
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontSize: { xs: '1.1rem', md: '1.5rem' },
|
||||
fontWeight: 400,
|
||||
opacity: 0.95,
|
||||
}}
|
||||
>
|
||||
{t('footer.tagline')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3, justifyContent: 'center', mt: 6, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: 'white',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
bgcolor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => router.push(`/${locale}/bible`)}
|
||||
>
|
||||
{t('footer.links.readBible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: 'white',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
bgcolor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => router.push(`/${locale}/prayers`)}
|
||||
>
|
||||
{t('footer.links.prayerWall')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: 'white',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
bgcolor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('floating-chat:open', { detail: { fullscreen: true } }))}
|
||||
>
|
||||
{t('footer.links.aiChat')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor: 'white',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
bgcolor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => router.push(`/${locale}/contact`)}
|
||||
>
|
||||
{t('footer.links.contact')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { PrismaClient, ChatMessageRole } from '@prisma/client'
|
||||
import { searchBibleHybrid, BibleVerse } from '@/lib/vector-search'
|
||||
import { verifyToken } from '@/lib/auth'
|
||||
import { checkConversationLimit, incrementConversationCount } from '@/lib/subscription-utils'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
@@ -57,6 +58,40 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check conversation limits for new conversations only
|
||||
if (userId && !conversationId) {
|
||||
try {
|
||||
const limitCheck = await checkConversationLimit(userId)
|
||||
|
||||
if (!limitCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Conversation limit reached. Upgrade to Premium for unlimited conversations.',
|
||||
code: 'LIMIT_REACHED',
|
||||
data: {
|
||||
limit: limitCheck.limit,
|
||||
remaining: limitCheck.remaining,
|
||||
tier: limitCheck.tier,
|
||||
resetDate: limitCheck.resetDate,
|
||||
upgradeUrl: `/${locale}/subscription`
|
||||
}
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Chat API - Limit check passed:', {
|
||||
tier: limitCheck.tier,
|
||||
remaining: limitCheck.remaining,
|
||||
limit: limitCheck.limit
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Chat API - Limit check error:', error)
|
||||
// Allow the request to proceed if limit check fails
|
||||
}
|
||||
}
|
||||
|
||||
// Handle conversation logic
|
||||
let finalConversationId = conversationId
|
||||
let conversationHistory: any[] = []
|
||||
@@ -104,6 +139,15 @@ export async function POST(request: Request) {
|
||||
}
|
||||
})
|
||||
finalConversationId = conversation.id
|
||||
|
||||
// Increment conversation count for free tier users
|
||||
try {
|
||||
await incrementConversationCount(userId)
|
||||
console.log('Chat API - Conversation count incremented for user:', userId)
|
||||
} catch (error) {
|
||||
console.error('Chat API - Failed to increment conversation count:', error)
|
||||
// Continue anyway - don't block the conversation
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Anonymous user - use provided history for backward compatibility
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { stripe } from '@/lib/stripe-server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import Stripe from 'stripe'
|
||||
import { getTierFromPriceId, getIntervalFromPriceId, getLimitForTier } from '@/lib/subscription-utils'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text()
|
||||
@@ -115,6 +116,134 @@ export async function POST(req: NextRequest) {
|
||||
break
|
||||
}
|
||||
|
||||
// Subscription events
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated': {
|
||||
const stripeSubscription = event.data.object as Stripe.Subscription
|
||||
const userId = stripeSubscription.metadata.userId
|
||||
|
||||
if (!userId) {
|
||||
console.warn('⚠️ No userId in subscription metadata:', stripeSubscription.id)
|
||||
break
|
||||
}
|
||||
|
||||
const priceId = stripeSubscription.items.data[0]?.price.id
|
||||
if (!priceId) {
|
||||
console.warn('⚠️ No price ID in subscription:', stripeSubscription.id)
|
||||
break
|
||||
}
|
||||
|
||||
const tier = getTierFromPriceId(priceId)
|
||||
const interval = getIntervalFromPriceId(priceId)
|
||||
const limit = getLimitForTier(tier)
|
||||
|
||||
// Upsert subscription record
|
||||
await prisma.subscription.upsert({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
create: {
|
||||
userId,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
stripePriceId: priceId,
|
||||
stripeCustomerId: stripeSubscription.customer as string,
|
||||
status: stripeSubscription.status.toUpperCase() as any,
|
||||
currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000),
|
||||
currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000),
|
||||
cancelAtPeriodEnd: (stripeSubscription as any).cancel_at_period_end,
|
||||
tier,
|
||||
interval
|
||||
},
|
||||
update: {
|
||||
status: stripeSubscription.status.toUpperCase() as any,
|
||||
currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000),
|
||||
currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000),
|
||||
cancelAtPeriodEnd: (stripeSubscription as any).cancel_at_period_end,
|
||||
stripePriceId: priceId
|
||||
}
|
||||
})
|
||||
|
||||
// Update user subscription tier and limit
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
subscriptionTier: tier,
|
||||
conversationLimit: limit,
|
||||
subscriptionStatus: stripeSubscription.status,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
stripeCustomerId: stripeSubscription.customer as string
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✅ Subscription ${stripeSubscription.status} for user ${userId} (tier: ${tier})`)
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
|
||||
const sub = await prisma.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
select: { userId: true }
|
||||
})
|
||||
|
||||
if (sub) {
|
||||
// Downgrade to free tier
|
||||
await prisma.user.update({
|
||||
where: { id: sub.userId },
|
||||
data: {
|
||||
subscriptionTier: 'free',
|
||||
conversationLimit: 10,
|
||||
subscriptionStatus: 'cancelled'
|
||||
}
|
||||
})
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { stripeSubscriptionId: subscription.id },
|
||||
data: { status: 'CANCELLED' }
|
||||
})
|
||||
|
||||
console.log(`✅ Subscription cancelled for user ${sub.userId}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_succeeded': {
|
||||
const invoice = event.data.object as any
|
||||
if (invoice.subscription) {
|
||||
console.log(`✅ Payment succeeded for subscription ${invoice.subscription}`)
|
||||
|
||||
// Ensure subscription is still active
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: invoice.subscription as string }
|
||||
})
|
||||
|
||||
if (subscription) {
|
||||
await prisma.user.update({
|
||||
where: { id: subscription.userId },
|
||||
data: { subscriptionStatus: 'active' }
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object as any
|
||||
if (invoice.subscription) {
|
||||
const subscription = await prisma.subscription.findUnique({
|
||||
where: { stripeSubscriptionId: invoice.subscription as string }
|
||||
})
|
||||
|
||||
if (subscription) {
|
||||
await prisma.user.update({
|
||||
where: { id: subscription.userId },
|
||||
data: { subscriptionStatus: 'past_due' }
|
||||
})
|
||||
console.warn(`⚠️ Payment failed for subscription ${invoice.subscription}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
|
||||
172
app/api/subscriptions/checkout/route.ts
Normal file
172
app/api/subscriptions/checkout/route.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { stripe } from '@/lib/stripe-server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { verifyToken } from '@/lib/auth'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const checkoutSchema = z.object({
|
||||
priceId: z.string(),
|
||||
interval: z.enum(['month', 'year']),
|
||||
locale: z.string().default('en')
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7)
|
||||
let payload
|
||||
try {
|
||||
payload = await verifyToken(token)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid or expired token' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = payload.userId
|
||||
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
stripeCustomerId: true,
|
||||
subscriptionTier: true,
|
||||
stripeSubscriptionId: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'User not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if already has active premium subscription
|
||||
if (user.subscriptionTier === 'premium' && user.stripeSubscriptionId) {
|
||||
// Check if subscription is actually active in Stripe
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId)
|
||||
if (subscription.status === 'active' || subscription.status === 'trialing') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Already subscribed to Premium',
|
||||
code: 'ALREADY_SUBSCRIBED'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Subscription not found in Stripe, allowing new subscription')
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { priceId, interval, locale } = checkoutSchema.parse(body)
|
||||
|
||||
// Validate price ID
|
||||
if (!priceId || priceId === 'price_xxxxxxxxxxxxx') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid price ID. Please configure Stripe price IDs in environment variables.'
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create or retrieve Stripe customer
|
||||
let customerId = user.stripeCustomerId
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
name: user.name || undefined,
|
||||
metadata: {
|
||||
userId,
|
||||
source: 'subscription'
|
||||
}
|
||||
})
|
||||
customerId = customer.id
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { stripeCustomerId: customerId }
|
||||
})
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1
|
||||
}
|
||||
],
|
||||
success_url: `${process.env.NEXTAUTH_URL}/${locale}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXTAUTH_URL}/${locale}/subscription`,
|
||||
metadata: {
|
||||
userId,
|
||||
interval
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
userId
|
||||
}
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: 'auto'
|
||||
})
|
||||
|
||||
console.log('✅ Stripe checkout session created:', {
|
||||
sessionId: session.id,
|
||||
userId,
|
||||
priceId,
|
||||
interval
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sessionId: session.id,
|
||||
url: session.url
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Subscription checkout error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request format',
|
||||
details: error.errors
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to create checkout session'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
106
app/api/subscriptions/portal/route.ts
Normal file
106
app/api/subscriptions/portal/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { stripe } from '@/lib/stripe-server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { verifyToken } from '@/lib/auth'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const portalSchema = z.object({
|
||||
locale: z.string().default('en')
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Verify authentication
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7)
|
||||
let payload
|
||||
try {
|
||||
payload = await verifyToken(token)
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid or expired token' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = payload.userId
|
||||
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
stripeCustomerId: true,
|
||||
subscriptionTier: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'User not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!user.stripeCustomerId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'No subscription found',
|
||||
code: 'NO_SUBSCRIPTION'
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { locale } = portalSchema.parse(body)
|
||||
|
||||
// Create billing portal session
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${process.env.NEXTAUTH_URL}/${locale}/settings`
|
||||
})
|
||||
|
||||
console.log('✅ Customer portal session created:', {
|
||||
sessionId: session.id,
|
||||
userId,
|
||||
customerId: user.stripeCustomerId
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
url: session.url
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Customer portal error:', error)
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid request format',
|
||||
details: error.errors
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to create portal session'
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user