create-baton 1.0.0 → 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/package.json +1 -1
- package/src/constants.js +3 -3
- package/templates/BATON_v3.1.md +53 -15
- package/templates/skills/domains/api/SKILL.md +318 -0
- package/templates/skills/domains/ecommerce/SKILL.md +277 -0
- package/templates/skills/domains/portfolio/SKILL.md +213 -0
- package/templates/skills/domains/saas/SKILL.md +252 -0
- package/templates/skills/patterns/authentication/SKILL.md +245 -0
- package/templates/skills/patterns/database-design/SKILL.md +230 -0
- package/templates/skills/patterns/email/SKILL.md +236 -0
- package/templates/skills/patterns/file-uploads/SKILL.md +216 -0
- package/templates/skills/patterns/payments/SKILL.md +246 -0
- package/templates/skills/patterns/seo/SKILL.md +219 -0
- package/templates/skills/stacks/prisma/SKILL.md +281 -0
- package/templates/skills/stacks/shadcn/SKILL.md +270 -0
- package/templates/skills/stacks/tailwind/SKILL.md +242 -0
- package/templates/skills/stacks/typescript/SKILL.md +241 -0
- package/templates/skills/stacks/vercel/SKILL.md +232 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: email
|
|
3
|
+
description: >-
|
|
4
|
+
Transactional email patterns — provider selection, template design,
|
|
5
|
+
deliverability, and common email flows (welcome, reset, notifications).
|
|
6
|
+
Load at Session 2-4 when the project needs to send emails to users.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Email Skill
|
|
10
|
+
|
|
11
|
+
> Users judge your app by your emails. Ugly emails = untrustworthy app.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Choose a Provider (Session 2)
|
|
16
|
+
|
|
17
|
+
| Provider | Best For | Free Tier |
|
|
18
|
+
|----------|----------|-----------|
|
|
19
|
+
| **Resend** | Modern, great DX, React Email templates | 3,000/month |
|
|
20
|
+
| **SendGrid** | Established, high volume | 100/day |
|
|
21
|
+
| **Postmark** | Transactional focus, great deliverability | 100/month |
|
|
22
|
+
| **AWS SES** | Cheapest at scale, more setup | 62,000/month (from EC2) |
|
|
23
|
+
|
|
24
|
+
**Default:** Resend. Best developer experience, React Email for templates, generous free tier.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Core Email Flows
|
|
29
|
+
|
|
30
|
+
### Which Emails to Send (Priority Order)
|
|
31
|
+
|
|
32
|
+
| Email | When | Priority |
|
|
33
|
+
|-------|------|----------|
|
|
34
|
+
| **Welcome** | After signup | Must have |
|
|
35
|
+
| **Password reset** | User requests it | Must have |
|
|
36
|
+
| **Email verification** | After signup (if required) | Should have |
|
|
37
|
+
| **Transactional** | Order confirmation, receipt | Must have (if payments) |
|
|
38
|
+
| **Notification** | New activity, updates | Nice to have |
|
|
39
|
+
|
|
40
|
+
Build in this order. Don't start with marketing emails.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Setup with Resend (Session 2-3)
|
|
45
|
+
|
|
46
|
+
### Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install resend
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Send an Email
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
// lib/email.ts
|
|
56
|
+
import { Resend } from 'resend';
|
|
57
|
+
|
|
58
|
+
const resend = new Resend(process.env.RESEND_API_KEY);
|
|
59
|
+
|
|
60
|
+
export async function sendEmail({
|
|
61
|
+
to,
|
|
62
|
+
subject,
|
|
63
|
+
html,
|
|
64
|
+
}: {
|
|
65
|
+
to: string;
|
|
66
|
+
subject: string;
|
|
67
|
+
html: string;
|
|
68
|
+
}) {
|
|
69
|
+
const { error } = await resend.emails.send({
|
|
70
|
+
from: 'App Name <noreply@yourdomain.com>',
|
|
71
|
+
to,
|
|
72
|
+
subject,
|
|
73
|
+
html,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (error) {
|
|
77
|
+
console.error('Email send failed:', error);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Environment Variables
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
RESEND_API_KEY=re_...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Email Templates
|
|
92
|
+
|
|
93
|
+
### Keep It Simple
|
|
94
|
+
|
|
95
|
+
Every email needs:
|
|
96
|
+
1. **Logo/app name** at top
|
|
97
|
+
2. **One clear message** (what happened)
|
|
98
|
+
3. **One clear action** (button/link)
|
|
99
|
+
4. **Footer** (company name, unsubscribe if marketing)
|
|
100
|
+
|
|
101
|
+
### Welcome Email
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
Subject: Welcome to [App Name]
|
|
105
|
+
|
|
106
|
+
Hi [Name],
|
|
107
|
+
|
|
108
|
+
Thanks for signing up.
|
|
109
|
+
|
|
110
|
+
Here's what you can do next:
|
|
111
|
+
→ [One primary action button]
|
|
112
|
+
|
|
113
|
+
If you need help, just reply to this email.
|
|
114
|
+
|
|
115
|
+
— The [App Name] team
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Password Reset
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
Subject: Reset your password
|
|
122
|
+
|
|
123
|
+
Hi,
|
|
124
|
+
|
|
125
|
+
Someone requested a password reset for your account.
|
|
126
|
+
|
|
127
|
+
[Reset Password button]
|
|
128
|
+
|
|
129
|
+
This link expires in 1 hour. If you didn't request this, ignore this email.
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Transactional (Order Confirmation)
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
Subject: Order #1234 confirmed
|
|
136
|
+
|
|
137
|
+
Hi [Name],
|
|
138
|
+
|
|
139
|
+
Your order is confirmed.
|
|
140
|
+
|
|
141
|
+
[Order summary table]
|
|
142
|
+
Item 1 — $XX.XX
|
|
143
|
+
Item 2 — $XX.XX
|
|
144
|
+
Total — $XX.XX
|
|
145
|
+
|
|
146
|
+
We'll email you when it ships.
|
|
147
|
+
|
|
148
|
+
[View Order button]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## React Email (If Using Resend)
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
// emails/welcome.tsx
|
|
157
|
+
import { Html, Head, Body, Container, Text, Button } from '@react-email/components';
|
|
158
|
+
|
|
159
|
+
export function WelcomeEmail({ name, url }: { name: string; url: string }) {
|
|
160
|
+
return (
|
|
161
|
+
<Html>
|
|
162
|
+
<Head />
|
|
163
|
+
<Body style={{ fontFamily: 'sans-serif', background: '#f9f9f9' }}>
|
|
164
|
+
<Container style={{ maxWidth: 480, margin: '0 auto', padding: 24 }}>
|
|
165
|
+
<Text style={{ fontSize: 20, fontWeight: 'bold' }}>
|
|
166
|
+
Welcome, {name}!
|
|
167
|
+
</Text>
|
|
168
|
+
<Text>Thanks for signing up. Get started:</Text>
|
|
169
|
+
<Button
|
|
170
|
+
href={url}
|
|
171
|
+
style={{
|
|
172
|
+
background: '#2563EB',
|
|
173
|
+
color: 'white',
|
|
174
|
+
padding: '12px 24px',
|
|
175
|
+
borderRadius: 6,
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
Go to Dashboard
|
|
179
|
+
</Button>
|
|
180
|
+
</Container>
|
|
181
|
+
</Body>
|
|
182
|
+
</Html>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Deliverability Basics
|
|
190
|
+
|
|
191
|
+
### Required Setup
|
|
192
|
+
|
|
193
|
+
1. **Custom domain** — send from `noreply@yourdomain.com`, not `@resend.dev`
|
|
194
|
+
2. **DNS records** — add SPF, DKIM, and DMARC records (provider gives you these)
|
|
195
|
+
3. **From name** — use your app name, not a person's name
|
|
196
|
+
|
|
197
|
+
### Rules
|
|
198
|
+
|
|
199
|
+
- Don't send from `noreply@gmail.com` or free email providers
|
|
200
|
+
- Keep subject lines short (under 50 characters)
|
|
201
|
+
- Don't use spam trigger words (FREE, URGENT, ACT NOW)
|
|
202
|
+
- Always include a plain text version alongside HTML
|
|
203
|
+
- Test with email preview tools before sending
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Error Handling
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
// Emails should never crash your app
|
|
211
|
+
try {
|
|
212
|
+
await sendEmail({ to, subject, html });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Log but don't block the user action
|
|
215
|
+
console.error('Failed to send email:', error);
|
|
216
|
+
// The user action (signup, order) should still succeed
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Rule:** Email sending failures should be logged, not thrown. Never let a failed email block a user from completing an action.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Common Mistakes
|
|
225
|
+
|
|
226
|
+
| Mistake | Fix |
|
|
227
|
+
|---------|-----|
|
|
228
|
+
| Sending from free email domain | Set up custom domain |
|
|
229
|
+
| No DNS records (SPF/DKIM) | Add them or emails go to spam |
|
|
230
|
+
| Blocking user actions on email failure | Send async, log errors |
|
|
231
|
+
| Building complex email templates early | Start with plain, styled text |
|
|
232
|
+
| Sending too many emails | Only send what users expect |
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
*Last updated: Baton Protocol v3.1*
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: file-uploads
|
|
3
|
+
description: >-
|
|
4
|
+
File upload patterns — storage strategy, direct uploads, presigned URLs,
|
|
5
|
+
image optimization, and validation. Load at Session 2-4 when the project
|
|
6
|
+
needs file uploads (avatars, images, documents). Use when implementing
|
|
7
|
+
any file upload feature.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# File Uploads Skill
|
|
11
|
+
|
|
12
|
+
> Files are the messiest feature. Validate early, store smart, serve fast.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Choose Storage (Session 1-2)
|
|
17
|
+
|
|
18
|
+
| Storage | Best For | Pricing |
|
|
19
|
+
|---------|----------|---------|
|
|
20
|
+
| **Supabase Storage** | Already using Supabase | 1GB free, then $0.021/GB |
|
|
21
|
+
| **Vercel Blob** | Simple, Next.js apps | 100MB free |
|
|
22
|
+
| **AWS S3** | Full control, large scale | Pay per GB |
|
|
23
|
+
| **Cloudflare R2** | S3-compatible, no egress fees | 10GB free |
|
|
24
|
+
|
|
25
|
+
**Default:** Use whatever your stack already includes. Supabase project? Use Supabase Storage. Don't add a new service just for files.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Upload Flow (Session 2-3)
|
|
30
|
+
|
|
31
|
+
### Direct Upload (Recommended)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Browser → Presigned URL from server → Upload directly to storage → Save URL in database
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Why direct:** Files don't pass through your server. Faster, cheaper, no server memory issues.
|
|
38
|
+
|
|
39
|
+
### Server Upload (Simple but Limited)
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Browser → Server → Storage → Save URL in database
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**When to use:** Small files only (<5MB), when presigned URLs are overkill.
|
|
46
|
+
|
|
47
|
+
### Presigned URL Pattern
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// 1. Client requests upload URL
|
|
51
|
+
// POST /api/upload/request
|
|
52
|
+
'use server'
|
|
53
|
+
export async function getUploadUrl(filename: string, contentType: string) {
|
|
54
|
+
const user = await getAuthenticatedUser();
|
|
55
|
+
if (!user) throw new Error('Not authenticated');
|
|
56
|
+
|
|
57
|
+
// Validate before generating URL
|
|
58
|
+
if (!ALLOWED_TYPES.includes(contentType)) {
|
|
59
|
+
throw new Error('Invalid file type');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const path = `${user.id}/${Date.now()}-${filename}`;
|
|
63
|
+
const { data, error } = await supabase.storage
|
|
64
|
+
.from('uploads')
|
|
65
|
+
.createSignedUploadUrl(path);
|
|
66
|
+
|
|
67
|
+
return { uploadUrl: data.signedUrl, path };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Client uploads directly to storage using the URL
|
|
71
|
+
// 3. Client sends the path back to save in database
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Validation (Always)
|
|
77
|
+
|
|
78
|
+
### Before Upload
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
|
82
|
+
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
|
83
|
+
|
|
84
|
+
function validateFile(file: File): string | null {
|
|
85
|
+
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
86
|
+
return 'File type not allowed';
|
|
87
|
+
}
|
|
88
|
+
if (file.size > MAX_SIZE) {
|
|
89
|
+
return 'File too large (max 10MB)';
|
|
90
|
+
}
|
|
91
|
+
return null; // valid
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### What to Validate
|
|
96
|
+
|
|
97
|
+
| Check | Why |
|
|
98
|
+
|-------|-----|
|
|
99
|
+
| File type (MIME) | Prevent executable uploads |
|
|
100
|
+
| File size | Prevent storage abuse |
|
|
101
|
+
| Filename | Sanitize special characters |
|
|
102
|
+
| Dimensions (images) | Prevent absurdly large images |
|
|
103
|
+
|
|
104
|
+
### Never Trust the Client
|
|
105
|
+
|
|
106
|
+
- Validate on server AND client
|
|
107
|
+
- Client validation is for UX (fast feedback)
|
|
108
|
+
- Server validation is for security (can't be bypassed)
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Image Optimization
|
|
113
|
+
|
|
114
|
+
### On Upload
|
|
115
|
+
|
|
116
|
+
- Resize to max dimensions (e.g., 1920px wide for display images)
|
|
117
|
+
- Generate thumbnails (200x200 for avatars, 400x300 for cards)
|
|
118
|
+
- Convert to WebP (smaller, supported everywhere)
|
|
119
|
+
|
|
120
|
+
### On Display
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
// Next.js Image handles optimization automatically
|
|
124
|
+
import Image from 'next/image';
|
|
125
|
+
|
|
126
|
+
<Image
|
|
127
|
+
src={imageUrl}
|
|
128
|
+
alt={description}
|
|
129
|
+
width={400}
|
|
130
|
+
height={300}
|
|
131
|
+
className="object-cover"
|
|
132
|
+
/>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Rule:** Always use Next.js `<Image>` for user-uploaded images. It handles lazy loading, sizing, and format conversion.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Avatar Upload Pattern
|
|
140
|
+
|
|
141
|
+
The most common file upload feature:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// Upload component
|
|
145
|
+
function AvatarUpload({ currentUrl }: { currentUrl?: string }) {
|
|
146
|
+
async function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
|
147
|
+
const file = e.target.files?.[0];
|
|
148
|
+
if (!file) return;
|
|
149
|
+
|
|
150
|
+
const error = validateFile(file);
|
|
151
|
+
if (error) { alert(error); return; }
|
|
152
|
+
|
|
153
|
+
// Get presigned URL
|
|
154
|
+
const { uploadUrl, path } = await getUploadUrl(file.name, file.type);
|
|
155
|
+
|
|
156
|
+
// Upload directly to storage
|
|
157
|
+
await fetch(uploadUrl, { method: 'PUT', body: file });
|
|
158
|
+
|
|
159
|
+
// Save path in database
|
|
160
|
+
await updateAvatar(path);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<label>
|
|
165
|
+
<img src={currentUrl || '/default-avatar.png'} />
|
|
166
|
+
<input type="file" accept="image/*" onChange={handleChange} hidden />
|
|
167
|
+
</label>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Storage Organization
|
|
175
|
+
|
|
176
|
+
### File Path Convention
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
{user_id}/{purpose}/{timestamp}-{filename}
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
abc123/avatars/1708300800-photo.jpg
|
|
183
|
+
abc123/documents/1708300800-contract.pdf
|
|
184
|
+
abc123/products/1708300800-hero-image.webp
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**Rules:**
|
|
188
|
+
- Always include user/org ID (for RLS and cleanup)
|
|
189
|
+
- Include timestamp (prevents collisions)
|
|
190
|
+
- Sanitize filename (remove special characters)
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Security
|
|
195
|
+
|
|
196
|
+
- Never serve user uploads from your main domain (use storage CDN URL)
|
|
197
|
+
- Set appropriate bucket policies (public for avatars, private for documents)
|
|
198
|
+
- Generate signed URLs for private files (expire in 1 hour)
|
|
199
|
+
- Scan for malware if handling sensitive documents (enterprise only)
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Common Mistakes
|
|
204
|
+
|
|
205
|
+
| Mistake | Fix |
|
|
206
|
+
|---------|-----|
|
|
207
|
+
| Storing files in database (BLOB) | Use object storage |
|
|
208
|
+
| No file size limit | Always set MAX_SIZE |
|
|
209
|
+
| Accepting any file type | Whitelist allowed MIME types |
|
|
210
|
+
| Uploading through server | Use presigned URLs for large files |
|
|
211
|
+
| Not handling upload failures | Show error, allow retry |
|
|
212
|
+
| No loading indicator | Show progress bar during upload |
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
*Last updated: Baton Protocol v3.1*
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: payments
|
|
3
|
+
description: >-
|
|
4
|
+
Payment integration patterns — Stripe Checkout, subscriptions, webhooks,
|
|
5
|
+
one-time payments, and billing portal. Load at Session 3-5 when the project
|
|
6
|
+
needs to accept payments. Use when discussing monetization or implementing
|
|
7
|
+
checkout.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Payments Skill
|
|
11
|
+
|
|
12
|
+
> Money code has zero margin for error. Use Stripe, don't build custom.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Rule #1: Use Stripe Checkout
|
|
17
|
+
|
|
18
|
+
Don't build a custom payment form. Ever. Stripe Checkout handles:
|
|
19
|
+
- PCI compliance (you don't want this responsibility)
|
|
20
|
+
- Card input with validation
|
|
21
|
+
- Apple Pay, Google Pay, Link
|
|
22
|
+
- 3D Secure authentication
|
|
23
|
+
- Tax calculation
|
|
24
|
+
- Receipt emails
|
|
25
|
+
- 135+ currencies
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Choose Your Payment Model
|
|
30
|
+
|
|
31
|
+
| Model | Use When | Stripe Feature |
|
|
32
|
+
|-------|----------|---------------|
|
|
33
|
+
| **One-time** | Selling a product or service | Checkout (payment mode) |
|
|
34
|
+
| **Subscription** | Monthly/annual SaaS | Checkout (subscription mode) |
|
|
35
|
+
| **Usage-based** | Pay per API call, storage, etc. | Metered billing |
|
|
36
|
+
| **Freemium** | Free tier + paid upgrade | Checkout + feature gating |
|
|
37
|
+
|
|
38
|
+
**Default for SaaS:** Subscription with annual discount.
|
|
39
|
+
**Default for e-commerce:** One-time payments.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## One-Time Payment (Session 3-4)
|
|
44
|
+
|
|
45
|
+
### Create Checkout Session
|
|
46
|
+
|
|
47
|
+
```typescript
|
|
48
|
+
'use server'
|
|
49
|
+
import Stripe from 'stripe';
|
|
50
|
+
|
|
51
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
52
|
+
|
|
53
|
+
export async function createCheckout(items: CartItem[]) {
|
|
54
|
+
const session = await stripe.checkout.sessions.create({
|
|
55
|
+
mode: 'payment',
|
|
56
|
+
line_items: items.map(item => ({
|
|
57
|
+
price_data: {
|
|
58
|
+
currency: 'usd',
|
|
59
|
+
product_data: { name: item.name },
|
|
60
|
+
unit_amount: item.priceCents,
|
|
61
|
+
},
|
|
62
|
+
quantity: item.quantity,
|
|
63
|
+
})),
|
|
64
|
+
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
65
|
+
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart`,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
redirect(session.url!);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Success Page
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// app/success/page.tsx
|
|
76
|
+
export default async function SuccessPage({ searchParams }) {
|
|
77
|
+
const sessionId = searchParams.session_id;
|
|
78
|
+
|
|
79
|
+
// Verify the session is real (don't trust the URL alone)
|
|
80
|
+
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
|
81
|
+
|
|
82
|
+
if (session.payment_status !== 'paid') {
|
|
83
|
+
redirect('/cart');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return <div>Payment successful! Order confirmed.</div>;
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Subscription (Session 3-5)
|
|
93
|
+
|
|
94
|
+
### Setup
|
|
95
|
+
|
|
96
|
+
1. Create products and prices in Stripe Dashboard (not in code)
|
|
97
|
+
2. Store Stripe `price_id` in your environment variables
|
|
98
|
+
3. Create checkout session with subscription mode
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const session = await stripe.checkout.sessions.create({
|
|
102
|
+
mode: 'subscription',
|
|
103
|
+
line_items: [{
|
|
104
|
+
price: process.env.STRIPE_PRO_PRICE_ID,
|
|
105
|
+
quantity: 1,
|
|
106
|
+
}],
|
|
107
|
+
customer_email: user.email,
|
|
108
|
+
success_url: `${baseUrl}/dashboard?upgraded=true`,
|
|
109
|
+
cancel_url: `${baseUrl}/pricing`,
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Customer Portal (Managing Subscriptions)
|
|
114
|
+
|
|
115
|
+
Don't build subscription management UI. Use Stripe's Customer Portal:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
export async function createPortalSession(customerId: string) {
|
|
119
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
120
|
+
customer: customerId,
|
|
121
|
+
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings`,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
redirect(session.url);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
This gives users: plan changes, cancellation, payment method updates, invoice history — all built by Stripe.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Webhooks (Critical)
|
|
133
|
+
|
|
134
|
+
### Setup
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// app/api/webhooks/stripe/route.ts
|
|
138
|
+
export async function POST(req: Request) {
|
|
139
|
+
const body = await req.text();
|
|
140
|
+
const signature = req.headers.get('stripe-signature')!;
|
|
141
|
+
|
|
142
|
+
let event: Stripe.Event;
|
|
143
|
+
try {
|
|
144
|
+
event = stripe.webhooks.constructEvent(
|
|
145
|
+
body,
|
|
146
|
+
signature,
|
|
147
|
+
process.env.STRIPE_WEBHOOK_SECRET!
|
|
148
|
+
);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return Response.json({ error: 'Invalid signature' }, { status: 400 });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
switch (event.type) {
|
|
154
|
+
case 'checkout.session.completed':
|
|
155
|
+
await handleCheckoutComplete(event.data.object);
|
|
156
|
+
break;
|
|
157
|
+
case 'customer.subscription.updated':
|
|
158
|
+
await handleSubscriptionUpdate(event.data.object);
|
|
159
|
+
break;
|
|
160
|
+
case 'customer.subscription.deleted':
|
|
161
|
+
await handleSubscriptionCancelled(event.data.object);
|
|
162
|
+
break;
|
|
163
|
+
case 'invoice.payment_failed':
|
|
164
|
+
await handlePaymentFailed(event.data.object);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return Response.json({ received: true });
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Critical Rules
|
|
173
|
+
|
|
174
|
+
- **Always verify signatures** — never skip this
|
|
175
|
+
- **Return 200 quickly** — do heavy processing async
|
|
176
|
+
- **Handle idempotently** — webhooks can fire multiple times
|
|
177
|
+
- **Log every event** — you'll need this for debugging
|
|
178
|
+
|
|
179
|
+
### Testing Webhooks Locally
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Install Stripe CLI
|
|
183
|
+
stripe listen --forward-to localhost:3000/api/webhooks/stripe
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
This gives you a temporary webhook secret for local development.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Pricing Page (Session 3-4)
|
|
191
|
+
|
|
192
|
+
### Standard Layout
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
Free Pro Enterprise
|
|
196
|
+
$0/mo $29/mo Custom
|
|
197
|
+
($290/year)
|
|
198
|
+
|
|
199
|
+
Feature 1 ✓ Feature 1 ✓ Feature 1 ✓
|
|
200
|
+
Feature 2 ✗ Feature 2 ✓ Feature 2 ✓
|
|
201
|
+
Feature 3 ✗ Feature 3 ✓ Feature 3 ✓
|
|
202
|
+
Feature 4 ✓ Feature 4 ✓
|
|
203
|
+
Feature 5 ✓
|
|
204
|
+
|
|
205
|
+
[Start Free] [Upgrade] [Contact Us]
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Rules:**
|
|
209
|
+
- Highlight the recommended plan (usually middle)
|
|
210
|
+
- Show annual pricing with savings ("Save 20%")
|
|
211
|
+
- List features, not technical specs
|
|
212
|
+
- Primary CTA on the plan you want people to pick
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Environment Variables
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# .env.local
|
|
220
|
+
STRIPE_SECRET_KEY=sk_test_... # Server only
|
|
221
|
+
STRIPE_WEBHOOK_SECRET=whsec_... # Server only
|
|
222
|
+
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # Client OK
|
|
223
|
+
|
|
224
|
+
# Price IDs (from Stripe Dashboard)
|
|
225
|
+
STRIPE_PRO_MONTHLY_PRICE_ID=price_...
|
|
226
|
+
STRIPE_PRO_ANNUAL_PRICE_ID=price_...
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Never expose the secret key to the client.**
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Common Mistakes
|
|
234
|
+
|
|
235
|
+
| Mistake | Fix |
|
|
236
|
+
|---------|-----|
|
|
237
|
+
| Building custom payment form | Use Stripe Checkout |
|
|
238
|
+
| Building subscription management UI | Use Stripe Customer Portal |
|
|
239
|
+
| Not verifying webhook signatures | Always verify |
|
|
240
|
+
| Trusting client-side payment confirmation | Verify via webhook |
|
|
241
|
+
| Hardcoding prices in UI | Pull from Stripe or env vars |
|
|
242
|
+
| Forgetting test mode vs live mode | Check keys before launch |
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
*Last updated: Baton Protocol v3.1*
|