@girardmedia/bootspring 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/agents/README.md +93 -0
- package/agents/api-expert/context.md +416 -0
- package/agents/architecture-expert/context.md +454 -0
- package/agents/backend-expert/context.md +483 -0
- package/agents/code-review-expert/context.md +365 -0
- package/agents/database-expert/context.md +250 -0
- package/agents/devops-expert/context.md +446 -0
- package/agents/frontend-expert/context.md +364 -0
- package/agents/index.js +140 -0
- package/agents/performance-expert/context.md +377 -0
- package/agents/security-expert/context.md +343 -0
- package/agents/testing-expert/context.md +414 -0
- package/agents/ui-ux-expert/context.md +448 -0
- package/agents/vercel-expert/context.md +426 -0
- package/bin/bootspring.js +310 -0
- package/cli/agent.js +337 -0
- package/cli/context.js +194 -0
- package/cli/dashboard.js +150 -0
- package/cli/generate.js +294 -0
- package/cli/init.js +410 -0
- package/cli/loop.js +421 -0
- package/cli/mcp.js +241 -0
- package/cli/memory.js +303 -0
- package/cli/orchestrator.js +400 -0
- package/cli/plugin.js +451 -0
- package/cli/quality.js +332 -0
- package/cli/skill.js +369 -0
- package/cli/task.js +628 -0
- package/cli/telemetry.js +114 -0
- package/cli/todo.js +614 -0
- package/cli/update.js +312 -0
- package/core/config.js +245 -0
- package/core/context.js +329 -0
- package/core/entitlements.js +209 -0
- package/core/index.js +43 -0
- package/core/policies.js +68 -0
- package/core/telemetry.js +247 -0
- package/core/utils.js +380 -0
- package/dashboard/server.js +818 -0
- package/docs/integrations/claude-code.md +42 -0
- package/docs/integrations/codex.md +42 -0
- package/docs/mcp-api-platform.md +102 -0
- package/generators/generate.js +598 -0
- package/generators/index.js +18 -0
- package/hooks/context-detector.js +177 -0
- package/hooks/index.js +35 -0
- package/hooks/prompt-enhancer.js +289 -0
- package/intelligence/git-memory.js +551 -0
- package/intelligence/index.js +59 -0
- package/intelligence/orchestrator.js +964 -0
- package/intelligence/prd.js +447 -0
- package/intelligence/recommendation-weights.json +18 -0
- package/intelligence/recommendations.js +234 -0
- package/mcp/capabilities.js +71 -0
- package/mcp/contracts/mcp-contract.v1.json +497 -0
- package/mcp/registry.js +213 -0
- package/mcp/response-formatter.js +462 -0
- package/mcp/server.js +99 -0
- package/mcp/tools/agent-tool.js +137 -0
- package/mcp/tools/capabilities-tool.js +54 -0
- package/mcp/tools/context-tool.js +49 -0
- package/mcp/tools/dashboard-tool.js +58 -0
- package/mcp/tools/generate-tool.js +46 -0
- package/mcp/tools/loop-tool.js +134 -0
- package/mcp/tools/memory-tool.js +180 -0
- package/mcp/tools/orchestrator-tool.js +232 -0
- package/mcp/tools/plugin-tool.js +76 -0
- package/mcp/tools/quality-tool.js +47 -0
- package/mcp/tools/skill-tool.js +233 -0
- package/mcp/tools/telemetry-tool.js +95 -0
- package/mcp/tools/todo-tool.js +133 -0
- package/package.json +98 -0
- package/plugins/index.js +141 -0
- package/quality/index.js +380 -0
- package/quality/lint-budgets.json +19 -0
- package/skills/index.js +787 -0
- package/skills/patterns/README.md +163 -0
- package/skills/patterns/api/route-handler.md +217 -0
- package/skills/patterns/api/server-action.md +249 -0
- package/skills/patterns/auth/clerk.md +132 -0
- package/skills/patterns/database/prisma.md +180 -0
- package/skills/patterns/payments/stripe.md +272 -0
- package/skills/patterns/security/validation.md +268 -0
- package/skills/patterns/testing/vitest.md +307 -0
- package/templates/bootspring.config.js +83 -0
- package/templates/mcp.json +9 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Clerk Authentication Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns for Clerk authentication in Next.js.
|
|
4
|
+
|
|
5
|
+
## Server-Side Auth Check
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// lib/auth.ts
|
|
9
|
+
import { auth } from '@clerk/nextjs/server'
|
|
10
|
+
|
|
11
|
+
export async function requireAuth() {
|
|
12
|
+
const { userId } = await auth()
|
|
13
|
+
|
|
14
|
+
if (!userId) {
|
|
15
|
+
throw new Error('UNAUTHORIZED')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return userId
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Usage in Server Action
|
|
22
|
+
export async function myAction() {
|
|
23
|
+
const userId = await requireAuth()
|
|
24
|
+
// Continue with authenticated user
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Protected Layout
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// app/(protected)/layout.tsx
|
|
32
|
+
import { auth } from '@clerk/nextjs/server'
|
|
33
|
+
import { redirect } from 'next/navigation'
|
|
34
|
+
|
|
35
|
+
export default async function ProtectedLayout({
|
|
36
|
+
children
|
|
37
|
+
}: {
|
|
38
|
+
children: React.ReactNode
|
|
39
|
+
}) {
|
|
40
|
+
const { userId } = await auth()
|
|
41
|
+
|
|
42
|
+
if (!userId) {
|
|
43
|
+
redirect('/sign-in')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return <>{children}</>
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Get Current User with Metadata
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// lib/auth.ts
|
|
54
|
+
import { currentUser } from '@clerk/nextjs/server'
|
|
55
|
+
import { prisma } from '@/lib/db'
|
|
56
|
+
|
|
57
|
+
export async function getCurrentUser() {
|
|
58
|
+
const user = await currentUser()
|
|
59
|
+
if (!user) return null
|
|
60
|
+
|
|
61
|
+
// Get or create database user
|
|
62
|
+
const dbUser = await prisma.user.upsert({
|
|
63
|
+
where: { clerkId: user.id },
|
|
64
|
+
update: {
|
|
65
|
+
email: user.emailAddresses[0]?.emailAddress,
|
|
66
|
+
name: `${user.firstName} ${user.lastName}`.trim()
|
|
67
|
+
},
|
|
68
|
+
create: {
|
|
69
|
+
clerkId: user.id,
|
|
70
|
+
email: user.emailAddresses[0]?.emailAddress ?? '',
|
|
71
|
+
name: `${user.firstName} ${user.lastName}`.trim()
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return dbUser
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Client-Side Auth Hook
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// hooks/use-user.ts
|
|
83
|
+
'use client'
|
|
84
|
+
import { useUser } from '@clerk/nextjs'
|
|
85
|
+
|
|
86
|
+
export function useCurrentUser() {
|
|
87
|
+
const { user, isLoaded, isSignedIn } = useUser()
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
user: isSignedIn ? user : null,
|
|
91
|
+
isLoading: !isLoaded,
|
|
92
|
+
isAuthenticated: isSignedIn
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Middleware Configuration
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
// middleware.ts
|
|
101
|
+
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
|
102
|
+
|
|
103
|
+
const isProtectedRoute = createRouteMatcher([
|
|
104
|
+
'/dashboard(.*)',
|
|
105
|
+
'/settings(.*)',
|
|
106
|
+
'/api/user(.*)'
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
const isPublicRoute = createRouteMatcher([
|
|
110
|
+
'/',
|
|
111
|
+
'/sign-in(.*)',
|
|
112
|
+
'/sign-up(.*)',
|
|
113
|
+
'/api/webhooks(.*)'
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
export default clerkMiddleware(async (auth, req) => {
|
|
117
|
+
if (isProtectedRoute(req)) {
|
|
118
|
+
await auth.protect()
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
export const config = {
|
|
123
|
+
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)']
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## When to Use
|
|
128
|
+
|
|
129
|
+
- Server Components needing user context
|
|
130
|
+
- Server Actions modifying user data
|
|
131
|
+
- API routes requiring authentication
|
|
132
|
+
- Protected page layouts
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Prisma ORM Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns for Prisma with Next.js.
|
|
4
|
+
|
|
5
|
+
## Client Singleton (Prevents Connection Exhaustion)
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// lib/db.ts
|
|
9
|
+
import { PrismaClient } from '@prisma/client'
|
|
10
|
+
|
|
11
|
+
const globalForPrisma = globalThis as unknown as {
|
|
12
|
+
prisma: PrismaClient | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
|
16
|
+
log: process.env.NODE_ENV === 'development'
|
|
17
|
+
? ['query', 'error', 'warn']
|
|
18
|
+
: ['error'],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
22
|
+
globalForPrisma.prisma = prisma
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Common Query Patterns
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// Create with relations
|
|
30
|
+
const user = await prisma.user.create({
|
|
31
|
+
data: {
|
|
32
|
+
email: 'john@example.com',
|
|
33
|
+
name: 'John Doe',
|
|
34
|
+
profile: {
|
|
35
|
+
create: { bio: 'Hello world' }
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
include: { profile: true }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Read with filters and pagination
|
|
42
|
+
const posts = await prisma.post.findMany({
|
|
43
|
+
where: {
|
|
44
|
+
published: true,
|
|
45
|
+
author: { email: { contains: '@example.com' } }
|
|
46
|
+
},
|
|
47
|
+
include: { author: { select: { name: true } } },
|
|
48
|
+
orderBy: { createdAt: 'desc' },
|
|
49
|
+
skip: (page - 1) * pageSize,
|
|
50
|
+
take: pageSize
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Update
|
|
54
|
+
const updated = await prisma.user.update({
|
|
55
|
+
where: { id: userId },
|
|
56
|
+
data: { name: 'Updated Name' }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Upsert (create or update)
|
|
60
|
+
const result = await prisma.user.upsert({
|
|
61
|
+
where: { email: 'user@example.com' },
|
|
62
|
+
update: { name: 'Updated' },
|
|
63
|
+
create: { email: 'user@example.com', name: 'New User' }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Soft delete pattern
|
|
67
|
+
const deleted = await prisma.user.update({
|
|
68
|
+
where: { id: userId },
|
|
69
|
+
data: { deletedAt: new Date() }
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Transactions
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// Sequential transaction (atomic)
|
|
77
|
+
const [user, post] = await prisma.$transaction([
|
|
78
|
+
prisma.user.create({ data: userData }),
|
|
79
|
+
prisma.post.create({ data: postData })
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
// Interactive transaction (with logic)
|
|
83
|
+
await prisma.$transaction(async (tx) => {
|
|
84
|
+
const user = await tx.user.findUnique({ where: { id: userId } })
|
|
85
|
+
if (!user) throw new Error('User not found')
|
|
86
|
+
if (user.credits < 10) throw new Error('Insufficient credits')
|
|
87
|
+
|
|
88
|
+
await tx.user.update({
|
|
89
|
+
where: { id: userId },
|
|
90
|
+
data: { credits: { decrement: 10 } }
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
await tx.transaction.create({
|
|
94
|
+
data: { userId, amount: -10, type: 'PURCHASE' }
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Server Action Pattern
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// actions/user.ts
|
|
103
|
+
'use server'
|
|
104
|
+
import { prisma } from '@/lib/db'
|
|
105
|
+
import { revalidatePath } from 'next/cache'
|
|
106
|
+
import { z } from 'zod'
|
|
107
|
+
|
|
108
|
+
const UpdateProfileSchema = z.object({
|
|
109
|
+
name: z.string().min(1).max(100),
|
|
110
|
+
bio: z.string().max(500).optional()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
export async function updateProfile(userId: string, formData: FormData) {
|
|
114
|
+
const validated = UpdateProfileSchema.parse({
|
|
115
|
+
name: formData.get('name'),
|
|
116
|
+
bio: formData.get('bio')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
await prisma.user.update({
|
|
120
|
+
where: { id: userId },
|
|
121
|
+
data: validated
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
revalidatePath('/profile')
|
|
125
|
+
return { success: true }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Migration Commands
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
# Development: create and apply migration
|
|
133
|
+
npx prisma migrate dev --name add_posts_table
|
|
134
|
+
|
|
135
|
+
# Production: apply pending migrations
|
|
136
|
+
npx prisma migrate deploy
|
|
137
|
+
|
|
138
|
+
# Regenerate client after schema changes
|
|
139
|
+
npx prisma generate
|
|
140
|
+
|
|
141
|
+
# Visual database browser
|
|
142
|
+
npx prisma studio
|
|
143
|
+
|
|
144
|
+
# Reset database (WARNING: destroys data)
|
|
145
|
+
npx prisma migrate reset
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Schema Example
|
|
149
|
+
|
|
150
|
+
```prisma
|
|
151
|
+
// prisma/schema.prisma
|
|
152
|
+
model User {
|
|
153
|
+
id String @id @default(cuid())
|
|
154
|
+
email String @unique
|
|
155
|
+
name String?
|
|
156
|
+
credits Int @default(0)
|
|
157
|
+
createdAt DateTime @default(now())
|
|
158
|
+
updatedAt DateTime @updatedAt
|
|
159
|
+
deletedAt DateTime?
|
|
160
|
+
|
|
161
|
+
posts Post[]
|
|
162
|
+
profile Profile?
|
|
163
|
+
|
|
164
|
+
@@index([email])
|
|
165
|
+
@@map("users")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
model Post {
|
|
169
|
+
id String @id @default(cuid())
|
|
170
|
+
title String
|
|
171
|
+
content String?
|
|
172
|
+
published Boolean @default(false)
|
|
173
|
+
authorId String
|
|
174
|
+
author User @relation(fields: [authorId], references: [id])
|
|
175
|
+
createdAt DateTime @default(now())
|
|
176
|
+
|
|
177
|
+
@@index([authorId])
|
|
178
|
+
@@map("posts")
|
|
179
|
+
}
|
|
180
|
+
```
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Stripe Payment Patterns
|
|
2
|
+
|
|
3
|
+
Battle-tested patterns for Stripe integration in Next.js.
|
|
4
|
+
|
|
5
|
+
## Stripe Client Setup
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
// lib/stripe.ts
|
|
9
|
+
import Stripe from 'stripe'
|
|
10
|
+
|
|
11
|
+
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
12
|
+
apiVersion: '2024-12-18.acacia',
|
|
13
|
+
typescript: true
|
|
14
|
+
})
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## One-Time Payment Checkout
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// app/api/checkout/route.ts
|
|
21
|
+
import { stripe } from '@/lib/stripe'
|
|
22
|
+
import { auth } from '@/lib/auth'
|
|
23
|
+
import { NextResponse } from 'next/server'
|
|
24
|
+
|
|
25
|
+
export async function POST(req: Request) {
|
|
26
|
+
const { userId } = await auth()
|
|
27
|
+
if (!userId) {
|
|
28
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { priceId, quantity = 1 } = await req.json()
|
|
32
|
+
|
|
33
|
+
const session = await stripe.checkout.sessions.create({
|
|
34
|
+
mode: 'payment',
|
|
35
|
+
payment_method_types: ['card'],
|
|
36
|
+
line_items: [{ price: priceId, quantity }],
|
|
37
|
+
success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
38
|
+
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
|
|
39
|
+
metadata: { userId }
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return NextResponse.json({ url: session.url })
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Subscription Checkout
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// app/api/subscribe/route.ts
|
|
50
|
+
import { stripe } from '@/lib/stripe'
|
|
51
|
+
import { auth } from '@/lib/auth'
|
|
52
|
+
import { prisma } from '@/lib/db'
|
|
53
|
+
|
|
54
|
+
export async function POST(req: Request) {
|
|
55
|
+
const { userId } = await auth()
|
|
56
|
+
if (!userId) {
|
|
57
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { priceId } = await req.json()
|
|
61
|
+
|
|
62
|
+
// Get or create Stripe customer
|
|
63
|
+
const user = await prisma.user.findUnique({ where: { id: userId } })
|
|
64
|
+
let customerId = user?.stripeCustomerId
|
|
65
|
+
|
|
66
|
+
if (!customerId) {
|
|
67
|
+
const customer = await stripe.customers.create({
|
|
68
|
+
email: user?.email,
|
|
69
|
+
metadata: { userId }
|
|
70
|
+
})
|
|
71
|
+
customerId = customer.id
|
|
72
|
+
|
|
73
|
+
await prisma.user.update({
|
|
74
|
+
where: { id: userId },
|
|
75
|
+
data: { stripeCustomerId: customerId }
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const session = await stripe.checkout.sessions.create({
|
|
80
|
+
mode: 'subscription',
|
|
81
|
+
customer: customerId,
|
|
82
|
+
line_items: [{ price: priceId, quantity: 1 }],
|
|
83
|
+
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=true`,
|
|
84
|
+
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
|
|
85
|
+
subscription_data: {
|
|
86
|
+
metadata: { userId }
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return NextResponse.json({ url: session.url })
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Webhook Handler
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// app/api/webhooks/stripe/route.ts
|
|
98
|
+
import { stripe } from '@/lib/stripe'
|
|
99
|
+
import { prisma } from '@/lib/db'
|
|
100
|
+
import { headers } from 'next/headers'
|
|
101
|
+
import Stripe from 'stripe'
|
|
102
|
+
|
|
103
|
+
export async function POST(req: Request) {
|
|
104
|
+
const body = await req.text()
|
|
105
|
+
const headersList = await headers()
|
|
106
|
+
const signature = headersList.get('stripe-signature')!
|
|
107
|
+
|
|
108
|
+
let event: Stripe.Event
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
event = stripe.webhooks.constructEvent(
|
|
112
|
+
body,
|
|
113
|
+
signature,
|
|
114
|
+
process.env.STRIPE_WEBHOOK_SECRET!
|
|
115
|
+
)
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('Webhook signature verification failed')
|
|
118
|
+
return new Response('Webhook Error', { status: 400 })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
switch (event.type) {
|
|
122
|
+
case 'checkout.session.completed': {
|
|
123
|
+
const session = event.data.object as Stripe.Checkout.Session
|
|
124
|
+
const userId = session.metadata?.userId
|
|
125
|
+
|
|
126
|
+
if (session.mode === 'subscription') {
|
|
127
|
+
await prisma.user.update({
|
|
128
|
+
where: { id: userId },
|
|
129
|
+
data: {
|
|
130
|
+
subscriptionId: session.subscription as string,
|
|
131
|
+
subscriptionStatus: 'active'
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
} else if (session.mode === 'payment') {
|
|
135
|
+
// Handle one-time payment (e.g., add credits)
|
|
136
|
+
await prisma.user.update({
|
|
137
|
+
where: { id: userId },
|
|
138
|
+
data: { credits: { increment: 100 } }
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case 'customer.subscription.updated':
|
|
145
|
+
case 'customer.subscription.deleted': {
|
|
146
|
+
const subscription = event.data.object as Stripe.Subscription
|
|
147
|
+
const userId = subscription.metadata.userId
|
|
148
|
+
|
|
149
|
+
await prisma.user.update({
|
|
150
|
+
where: { id: userId },
|
|
151
|
+
data: {
|
|
152
|
+
subscriptionStatus: subscription.status,
|
|
153
|
+
currentPeriodEnd: new Date(subscription.current_period_end * 1000)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'invoice.payment_failed': {
|
|
160
|
+
const invoice = event.data.object as Stripe.Invoice
|
|
161
|
+
// Send email notification, update status, etc.
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return new Response('OK', { status: 200 })
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Client-Side Checkout Button
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
// components/checkout-button.tsx
|
|
174
|
+
'use client'
|
|
175
|
+
|
|
176
|
+
import { useState } from 'react'
|
|
177
|
+
|
|
178
|
+
export function CheckoutButton({
|
|
179
|
+
priceId,
|
|
180
|
+
mode = 'payment'
|
|
181
|
+
}: {
|
|
182
|
+
priceId: string
|
|
183
|
+
mode?: 'payment' | 'subscription'
|
|
184
|
+
}) {
|
|
185
|
+
const [loading, setLoading] = useState(false)
|
|
186
|
+
|
|
187
|
+
const handleCheckout = async () => {
|
|
188
|
+
setLoading(true)
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const endpoint = mode === 'subscription' ? '/api/subscribe' : '/api/checkout'
|
|
192
|
+
const res = await fetch(endpoint, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json' },
|
|
195
|
+
body: JSON.stringify({ priceId })
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const { url, error } = await res.json()
|
|
199
|
+
|
|
200
|
+
if (error) {
|
|
201
|
+
alert(error)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
window.location.href = url
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error('Checkout error:', error)
|
|
208
|
+
alert('Something went wrong')
|
|
209
|
+
} finally {
|
|
210
|
+
setLoading(false)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
onClick={handleCheckout}
|
|
217
|
+
disabled={loading}
|
|
218
|
+
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
|
|
219
|
+
>
|
|
220
|
+
{loading ? 'Loading...' : mode === 'subscription' ? 'Subscribe' : 'Buy Now'}
|
|
221
|
+
</button>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Customer Portal (Manage Subscription)
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
// app/api/billing/portal/route.ts
|
|
230
|
+
import { stripe } from '@/lib/stripe'
|
|
231
|
+
import { auth } from '@/lib/auth'
|
|
232
|
+
import { prisma } from '@/lib/db'
|
|
233
|
+
|
|
234
|
+
export async function POST() {
|
|
235
|
+
const { userId } = await auth()
|
|
236
|
+
if (!userId) {
|
|
237
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const user = await prisma.user.findUnique({ where: { id: userId } })
|
|
241
|
+
if (!user?.stripeCustomerId) {
|
|
242
|
+
return NextResponse.json({ error: 'No billing account' }, { status: 400 })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
246
|
+
customer: user.stripeCustomerId,
|
|
247
|
+
return_url: `${process.env.NEXT_PUBLIC_URL}/settings/billing`
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
return NextResponse.json({ url: session.url })
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## Environment Variables
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
# .env.local
|
|
258
|
+
STRIPE_SECRET_KEY=sk_test_...
|
|
259
|
+
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
260
|
+
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Testing Webhooks Locally
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
# Install Stripe CLI
|
|
267
|
+
brew install stripe/stripe-cli/stripe
|
|
268
|
+
|
|
269
|
+
# Login and forward webhooks
|
|
270
|
+
stripe login
|
|
271
|
+
stripe listen --forward-to localhost:3000/api/webhooks/stripe
|
|
272
|
+
```
|