Add Stripe subscription system and n8n chat webhook
- Add Stripe SDK and subscription management - Create checkout, webhook, and portal API routes - Add pricing page with plan cards - Create subscriptions table in Supabase - Update database types for subscriptions - Configure n8n webhook for AI chat responses 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
119
src/app/(marketing)/pricing/page.tsx
Normal file
119
src/app/(marketing)/pricing/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { PLANS, type PlanKey } from '@/lib/stripe/config'
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState<PlanKey | null>(null)
|
||||
|
||||
const handleSubscribe = async (plan: PlanKey) => {
|
||||
if (plan === 'free') {
|
||||
router.push('/auth/signup')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(plan)
|
||||
try {
|
||||
const res = await fetch('/api/stripe/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error === 'Unauthorized') {
|
||||
router.push('/auth/login?redirect=/pricing')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error)
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-zinc-50 to-white dark:from-zinc-950 dark:to-zinc-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-4">
|
||||
Simple, transparent pricing
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-600 dark:text-zinc-400 max-w-2xl mx-auto">
|
||||
Start free, upgrade when you need more. No hidden fees.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{(Object.entries(PLANS) as [PlanKey, typeof PLANS[PlanKey]][]).map(([key, plan]) => (
|
||||
<Card
|
||||
key={key}
|
||||
className={`relative ${
|
||||
key === 'pro' ? 'border-brand shadow-lg scale-105' : ''
|
||||
}`}
|
||||
>
|
||||
{key === 'pro' && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2" variant="brand">
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle>{plan.name}</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
${plan.price}
|
||||
</span>
|
||||
{plan.price > 0 && (
|
||||
<span className="text-muted-foreground">/month</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-brand shrink-0 mt-0.5" />
|
||||
<span className="text-sm">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={key === 'pro' ? 'brand' : 'outline'}
|
||||
onClick={() => handleSubscribe(key)}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === key ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : key === 'free' ? (
|
||||
'Get Started'
|
||||
) : (
|
||||
'Subscribe'
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||
<p>All plans include SSL encryption and 99.9% uptime SLA.</p>
|
||||
<p className="mt-2">Questions? Contact support@mylder.io</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
src/app/api/stripe/checkout/route.ts
Normal file
66
src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { stripe, PLANS, type PlanKey } from '@/lib/stripe/config'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row']
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { plan } = await request.json() as { plan: PlanKey }
|
||||
const planConfig = PLANS[plan]
|
||||
|
||||
if (!planConfig || !planConfig.priceId) {
|
||||
return NextResponse.json({ error: 'Invalid plan' }, { status: 400 })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: subscription } = await (supabase as any)
|
||||
.from('subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
|
||||
|
||||
let customerId = subscription?.stripe_customer_id
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: { userId: user.id },
|
||||
})
|
||||
customerId = customer.id
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (supabase as any)
|
||||
.from('subscriptions')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
stripe_customer_id: customerId,
|
||||
plan: 'free',
|
||||
status: 'active',
|
||||
})
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{ price: planConfig.priceId, quantity: 1 }],
|
||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?checkout=success`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?checkout=cancelled`,
|
||||
metadata: { userId: user.id, plan },
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: session.url })
|
||||
} catch (error) {
|
||||
console.error('Checkout error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
38
src/app/api/stripe/portal/route.ts
Normal file
38
src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { stripe } from '@/lib/stripe/config'
|
||||
import type { Database } from '@/types/database'
|
||||
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row']
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: subscription } = await (supabase as any)
|
||||
.from('subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('user_id', user.id)
|
||||
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
|
||||
|
||||
if (!subscription?.stripe_customer_id) {
|
||||
return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: subscription.stripe_customer_id,
|
||||
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/settings`,
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: session.url })
|
||||
} catch (error) {
|
||||
console.error('Portal error:', error)
|
||||
return NextResponse.json({ error: 'Failed to create portal session' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
103
src/app/api/stripe/webhook/route.ts
Normal file
103
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { headers } from 'next/headers'
|
||||
import { stripe } from '@/lib/stripe/config'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text()
|
||||
const headersList = await headers()
|
||||
const signature = headersList.get('stripe-signature')!
|
||||
|
||||
let event: Stripe.Event
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object as Stripe.Checkout.Session
|
||||
const userId = session.metadata?.userId
|
||||
const plan = session.metadata?.plan
|
||||
|
||||
if (userId && session.subscription) {
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
session.subscription as string
|
||||
)
|
||||
|
||||
await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
stripe_customer_id: session.customer as string,
|
||||
stripe_subscription_id: subscription.id,
|
||||
plan: plan || 'pro',
|
||||
status: subscription.status,
|
||||
current_period_start: new Date((subscription as unknown as { current_period_start: number }).current_period_start * 1000).toISOString(),
|
||||
current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(),
|
||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object as Stripe.Subscription
|
||||
const customerId = subscription.customer as string
|
||||
|
||||
const { data: existingSub } = await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.select('user_id')
|
||||
.eq('stripe_customer_id', customerId)
|
||||
.single()
|
||||
|
||||
if (existingSub) {
|
||||
const sub = subscription as unknown as { current_period_start: number; current_period_end: number }
|
||||
await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: subscription.status,
|
||||
plan: subscription.status === 'canceled' ? 'free' : undefined,
|
||||
current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
|
||||
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
|
||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('stripe_customer_id', customerId)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
const customerId = invoice.customer as string
|
||||
|
||||
await supabaseAdmin
|
||||
.from('subscriptions')
|
||||
.update({ status: 'past_due', updated_at: new Date().toISOString() })
|
||||
.eq('stripe_customer_id', customerId)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true })
|
||||
} catch (error) {
|
||||
console.error('Webhook processing error:', error)
|
||||
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
12
src/lib/stripe/client.ts
Normal file
12
src/lib/stripe/client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { loadStripe, type Stripe } from '@stripe/stripe-js'
|
||||
|
||||
let stripePromise: Promise<Stripe | null> | null = null
|
||||
|
||||
export function getStripe() {
|
||||
if (!stripePromise) {
|
||||
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
|
||||
}
|
||||
return stripePromise
|
||||
}
|
||||
57
src/lib/stripe/config.ts
Normal file
57
src/lib/stripe/config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import Stripe from 'stripe'
|
||||
|
||||
const stripeSecretKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder'
|
||||
|
||||
export const stripe = new Stripe(stripeSecretKey)
|
||||
|
||||
export const PLANS = {
|
||||
free: {
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
priceId: null,
|
||||
features: [
|
||||
'1 project',
|
||||
'50 AI messages/month',
|
||||
'Basic templates',
|
||||
'Community support',
|
||||
],
|
||||
limits: {
|
||||
projects: 1,
|
||||
messages: 50,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
name: 'Pro',
|
||||
price: 19,
|
||||
priceId: process.env.STRIPE_PRO_PRICE_ID,
|
||||
features: [
|
||||
'Unlimited projects',
|
||||
'2,000 AI messages/month',
|
||||
'All templates',
|
||||
'Priority support',
|
||||
'API access',
|
||||
],
|
||||
limits: {
|
||||
projects: -1,
|
||||
messages: 2000,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
name: 'Team',
|
||||
price: 49,
|
||||
priceId: process.env.STRIPE_TEAM_PRICE_ID,
|
||||
features: [
|
||||
'Everything in Pro',
|
||||
'10,000 AI messages/month',
|
||||
'Team collaboration',
|
||||
'Admin dashboard',
|
||||
'Custom integrations',
|
||||
],
|
||||
limits: {
|
||||
projects: -1,
|
||||
messages: 10000,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export type PlanKey = keyof typeof PLANS
|
||||
@@ -233,6 +233,47 @@ export interface Database {
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
subscriptions: {
|
||||
Row: {
|
||||
id: string
|
||||
user_id: string
|
||||
stripe_customer_id: string | null
|
||||
stripe_subscription_id: string | null
|
||||
plan: 'free' | 'pro' | 'team'
|
||||
status: 'active' | 'canceled' | 'past_due' | 'incomplete'
|
||||
current_period_start: string | null
|
||||
current_period_end: string | null
|
||||
cancel_at_period_end: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
user_id: string
|
||||
stripe_customer_id?: string | null
|
||||
stripe_subscription_id?: string | null
|
||||
plan?: 'free' | 'pro' | 'team'
|
||||
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
|
||||
current_period_start?: string | null
|
||||
current_period_end?: string | null
|
||||
cancel_at_period_end?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
user_id?: string
|
||||
stripe_customer_id?: string | null
|
||||
stripe_subscription_id?: string | null
|
||||
plan?: 'free' | 'pro' | 'team'
|
||||
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
|
||||
current_period_start?: string | null
|
||||
current_period_end?: string | null
|
||||
cancel_at_period_end?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
|
||||
Reference in New Issue
Block a user