Add Stripe subscription billing integration

- Add stripe_customer_id and stripe_subscription_id fields to User model
- Add Stripe config settings (secret key, publishable key, price ID, webhook secret)
- Create billing API endpoints: checkout session, webhook handler, portal, status
- Add frontend Billing page with upgrade/manage subscription UI
- Add billing route and Pro nav link
- Add stripe dependency to requirements
This commit is contained in:
root
2026-03-30 21:38:40 -05:00
parent 58c17498be
commit b97955d004
7 changed files with 360 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ import Playlists from './pages/Playlists'
import PlaylistDetail from './pages/PlaylistDetail'
import Discover from './pages/Discover'
import Recommendations from './pages/Recommendations'
import Billing from './pages/Billing'
function RootRedirect() {
const { user, loading } = useAuth()
@@ -82,6 +83,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/billing"
element={
<ProtectedRoute>
<Layout>
<Billing />
</Layout>
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Menu, X, LogOut, User } from 'lucide-react'
import { Disc3, LayoutDashboard, ListMusic, Compass, Heart, Crown, Menu, X, LogOut, User } from 'lucide-react'
import { useAuth } from '../lib/auth'
const navItems = [
@@ -8,6 +8,7 @@ const navItems = [
{ path: '/playlists', label: 'Playlists', icon: ListMusic },
{ path: '/discover', label: 'Discover', icon: Compass },
{ path: '/saved', label: 'Saved', icon: Heart },
{ path: '/billing', label: 'Pro', icon: Crown },
]
export default function Layout({ children }: { children: React.ReactNode }) {

View File

@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Crown, Check, Loader2, ExternalLink, Sparkles, Music, Infinity, Download } from 'lucide-react'
import { useAuth } from '../lib/auth'
import { createCheckout, createBillingPortal, getBillingStatus } from '../lib/api'
interface BillingInfo {
is_pro: boolean
subscription_status: string | null
current_period_end: number | null
}
const proFeatures = [
{ icon: Infinity, text: 'Unlimited recommendations per day' },
{ icon: Music, text: 'Unlimited playlist imports' },
{ icon: Sparkles, text: 'Advanced taste analysis' },
{ icon: Download, text: 'Export playlists to any platform' },
]
export default function Billing() {
const { user, refreshUser } = useAuth()
const [searchParams] = useSearchParams()
const [billing, setBilling] = useState<BillingInfo | null>(null)
const [loading, setLoading] = useState(true)
const [checkoutLoading, setCheckoutLoading] = useState(false)
const [portalLoading, setPortalLoading] = useState(false)
const success = searchParams.get('success') === 'true'
const canceled = searchParams.get('canceled') === 'true'
useEffect(() => {
getBillingStatus()
.then(setBilling)
.catch(() => setBilling({ is_pro: user?.is_pro || false, subscription_status: null, current_period_end: null }))
.finally(() => setLoading(false))
}, [user?.is_pro])
useEffect(() => {
if (success && refreshUser) {
refreshUser()
}
}, [success, refreshUser])
const handleUpgrade = async () => {
setCheckoutLoading(true)
try {
const { url } = await createCheckout()
window.location.href = url
} catch {
setCheckoutLoading(false)
}
}
const handleManage = async () => {
setPortalLoading(true)
try {
const { url } = await createBillingPortal()
window.location.href = url
} catch {
setPortalLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-purple animate-spin" />
</div>
)
}
const isPro = billing?.is_pro || false
return (
<div className="max-w-2xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-charcoal">Billing</h1>
<p className="text-charcoal-muted mt-1">Manage your subscription</p>
</div>
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl">
<p className="text-green-800 font-medium">Welcome to Vynl Pro! Your subscription is now active.</p>
</div>
)}
{canceled && (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-amber-800 font-medium">Checkout was canceled. No charges were made.</p>
</div>
)}
{/* Current Plan */}
<div className="bg-white rounded-2xl border border-purple-100 overflow-hidden">
<div className="p-6 border-b border-purple-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${isPro ? 'bg-purple' : 'bg-purple-50'}`}>
<Crown className={`w-5 h-5 ${isPro ? 'text-white' : 'text-purple'}`} />
</div>
<div>
<h2 className="text-lg font-semibold text-charcoal">
{isPro ? 'Vynl Pro' : 'Free Plan'}
</h2>
<p className="text-sm text-charcoal-muted">
{isPro ? '$4.99/month' : '10 recommendations/day, 1 playlist'}
</p>
</div>
</div>
{isPro && billing?.subscription_status && (
<span className="px-3 py-1 bg-green-50 text-green-700 text-sm font-medium rounded-full">
{billing.subscription_status === 'active' ? 'Active' : billing.subscription_status}
</span>
)}
</div>
{isPro && billing?.current_period_end && (
<p className="text-sm text-charcoal-muted mt-3">
Next billing date: {new Date(billing.current_period_end * 1000).toLocaleDateString()}
</p>
)}
</div>
{/* Pro Features */}
<div className="p-6">
<h3 className="text-sm font-semibold text-charcoal uppercase tracking-wider mb-4">
{isPro ? 'Your Pro features' : 'Upgrade to Pro'}
</h3>
<div className="space-y-3">
{proFeatures.map((feature) => {
const Icon = feature.icon
return (
<div key={feature.text} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-purple-50 flex items-center justify-center flex-shrink-0">
<Icon className="w-4 h-4 text-purple" />
</div>
<span className="text-sm text-charcoal">{feature.text}</span>
{isPro && <Check className="w-4 h-4 text-green-500 ml-auto" />}
</div>
)
})}
</div>
</div>
{/* Action */}
<div className="p-6 bg-purple-50/50 border-t border-purple-100">
{isPro ? (
<button
onClick={handleManage}
disabled={portalLoading}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-white border border-purple-200 text-purple font-semibold rounded-xl hover:bg-purple-50 transition-colors cursor-pointer disabled:opacity-50"
>
{portalLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<ExternalLink className="w-4 h-4" />
Manage Subscription
</>
)}
</button>
) : (
<button
onClick={handleUpgrade}
disabled={checkoutLoading}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-purple text-white font-semibold rounded-xl hover:bg-purple-dark transition-colors cursor-pointer disabled:opacity-50"
>
{checkoutLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Crown className="w-4 h-4" />
Upgrade to Pro $4.99/mo
</>
)}
</button>
)}
</div>
</div>
</div>
)
}