stripe-no-webhooks 0.0.8 → 0.0.11
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/README.md +118 -104
- package/bin/cli.js +41 -852
- package/bin/commands/backfill.js +389 -0
- package/bin/commands/config.js +272 -0
- package/bin/commands/generate.js +110 -0
- package/bin/commands/helpers/backfill-maps.js +279 -0
- package/bin/commands/helpers/dev-webhook-listener.js +76 -0
- package/bin/commands/helpers/sync-helpers.js +190 -0
- package/bin/commands/helpers/utils.js +168 -0
- package/bin/commands/migrate.js +104 -0
- package/bin/commands/sync.js +433 -0
- package/dist/BillingConfig-CpHPJg4Q.d.mts +54 -0
- package/dist/BillingConfig-CpHPJg4Q.d.ts +54 -0
- package/dist/client.d.mts +32 -8
- package/dist/client.d.ts +32 -8
- package/dist/client.js +82 -20
- package/dist/client.mjs +80 -19
- package/dist/index.d.mts +460 -66
- package/dist/index.d.ts +460 -66
- package/dist/index.js +2736 -168
- package/dist/index.mjs +2730 -167
- package/package.json +9 -3
- package/src/templates/PricingPage.tsx +450 -0
- package/src/templates/app-router.ts +23 -16
- package/src/templates/lib-billing.ts +27 -0
- package/src/templates/pages-router.ts +25 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stripe-no-webhooks",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"author": "Ramon Garate",
|
|
5
5
|
"description": "Stripe integration without dealing with webhooks",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -29,18 +29,24 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsup src/index.ts src/client.ts --format cjs,esm --dts",
|
|
31
31
|
"dev": "tsup src/index.ts src/client.ts --format cjs,esm --dts --watch",
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"test:db:up": "docker compose -f tests/docker-compose.yml up -d && sleep 2",
|
|
34
|
+
"test:db:down": "docker compose -f tests/docker-compose.yml down",
|
|
35
|
+
"test:credits": "bun test tests/credits.test.ts",
|
|
32
36
|
"prepublishOnly": "npm run build",
|
|
33
37
|
"release": "npm version patch && npm publish"
|
|
34
38
|
},
|
|
35
39
|
"dependencies": {
|
|
36
|
-
"@
|
|
37
|
-
"dotenv": "^17.2.3"
|
|
40
|
+
"@pretzelai/stripe-sync-engine": "^0.48.1",
|
|
41
|
+
"dotenv": "^17.2.3",
|
|
42
|
+
"pg": "^8.13.1"
|
|
38
43
|
},
|
|
39
44
|
"peerDependencies": {
|
|
40
45
|
"stripe": ">=14.0.0"
|
|
41
46
|
},
|
|
42
47
|
"devDependencies": {
|
|
43
48
|
"@types/node": "^22.10.2",
|
|
49
|
+
"@types/pg": "^8.16.0",
|
|
44
50
|
"stripe": "^17.4.0",
|
|
45
51
|
"tsup": "^8.3.5",
|
|
46
52
|
"typescript": "^5.7.2"
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo, useEffect } from "react";
|
|
4
|
+
import { createCheckoutClient } from "stripe-no-webhooks/client";
|
|
5
|
+
import type { Plan, PriceInterval } from "stripe-no-webhooks";
|
|
6
|
+
|
|
7
|
+
interface PricingPageProps {
|
|
8
|
+
plans: Plan[];
|
|
9
|
+
currentPlanId?: string;
|
|
10
|
+
currentInterval?: PriceInterval;
|
|
11
|
+
onError?: (error: Error) => void;
|
|
12
|
+
/** Countdown duration in seconds before redirecting after plan switch (default: 5) */
|
|
13
|
+
redirectCountdown?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const getPlanId = (plan: Plan) =>
|
|
17
|
+
plan.id || plan.name.toLowerCase().replace(/\s+/g, "-");
|
|
18
|
+
|
|
19
|
+
const getPrice = (plan: Plan, interval: PriceInterval) => {
|
|
20
|
+
if (!plan.price || plan.price.length === 0) return null;
|
|
21
|
+
return plan.price.find((p) => p.interval === interval) || plan.price[0];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function PricingPage({
|
|
25
|
+
plans,
|
|
26
|
+
currentPlanId,
|
|
27
|
+
currentInterval = "month",
|
|
28
|
+
onError,
|
|
29
|
+
redirectCountdown = 5,
|
|
30
|
+
}: PricingPageProps) {
|
|
31
|
+
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [interval, setInterval] = useState<PriceInterval>(currentInterval);
|
|
34
|
+
const [countdown, setCountdown] = useState<number | null>(null);
|
|
35
|
+
const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
// Handle countdown and redirect
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (countdown === null || !redirectUrl) return;
|
|
40
|
+
|
|
41
|
+
if (countdown === 0) {
|
|
42
|
+
window.location.href = redirectUrl;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
setCountdown(countdown - 1);
|
|
48
|
+
}, 1000);
|
|
49
|
+
|
|
50
|
+
return () => clearTimeout(timer);
|
|
51
|
+
}, [countdown, redirectUrl]);
|
|
52
|
+
|
|
53
|
+
const { checkout, customerPortal } = useMemo(
|
|
54
|
+
() =>
|
|
55
|
+
createCheckoutClient({
|
|
56
|
+
onLoading: (isLoading) => {
|
|
57
|
+
if (!isLoading) setLoadingPlanId(null);
|
|
58
|
+
},
|
|
59
|
+
onError: (err) => {
|
|
60
|
+
setError(err.message);
|
|
61
|
+
onError?.(err);
|
|
62
|
+
},
|
|
63
|
+
onPlanChanged: (url) => {
|
|
64
|
+
setRedirectUrl(url);
|
|
65
|
+
setCountdown(redirectCountdown);
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
[onError, redirectCountdown]
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const handleCheckout = async (plan: Plan) => {
|
|
72
|
+
setLoadingPlanId(getPlanId(plan));
|
|
73
|
+
setError(null);
|
|
74
|
+
setCountdown(null);
|
|
75
|
+
setRedirectUrl(null);
|
|
76
|
+
await checkout({ planId: getPlanId(plan), interval });
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const handleManage = async () => {
|
|
80
|
+
setLoadingPlanId("manage");
|
|
81
|
+
setError(null);
|
|
82
|
+
setCountdown(null);
|
|
83
|
+
setRedirectUrl(null);
|
|
84
|
+
await customerPortal();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const formatPrice = (amount: number, currency: string) => {
|
|
88
|
+
return new Intl.NumberFormat("en-US", {
|
|
89
|
+
style: "currency",
|
|
90
|
+
currency: currency.toUpperCase(),
|
|
91
|
+
minimumFractionDigits: 0,
|
|
92
|
+
}).format(amount / 100);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Check if we should show interval toggle (must have both month AND year prices)
|
|
96
|
+
const hasMultipleIntervals = useMemo(() => {
|
|
97
|
+
const allIntervals = new Set<PriceInterval>();
|
|
98
|
+
for (const plan of plans) {
|
|
99
|
+
for (const price of plan.price || []) {
|
|
100
|
+
if (price.interval !== "one_time") {
|
|
101
|
+
allIntervals.add(price.interval);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return allIntervals.has("month") && allIntervals.has("year");
|
|
106
|
+
}, [plans]);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<style>{`
|
|
111
|
+
/* =================================================================
|
|
112
|
+
CUSTOMIZE YOUR THEME
|
|
113
|
+
Change these variables to match your brand colors and style.
|
|
114
|
+
================================================================= */
|
|
115
|
+
.snw-pricing-container {
|
|
116
|
+
--snw-primary: #3b82f6;
|
|
117
|
+
--snw-primary-hover: #2563eb;
|
|
118
|
+
--snw-text: #111;
|
|
119
|
+
--snw-text-muted: #666;
|
|
120
|
+
--snw-text-secondary: #374151;
|
|
121
|
+
--snw-border: #e5e7eb;
|
|
122
|
+
--snw-background: white;
|
|
123
|
+
--snw-background-secondary: #f3f4f6;
|
|
124
|
+
--snw-success: #16a34a;
|
|
125
|
+
--snw-success-bg: #f0fdf4;
|
|
126
|
+
--snw-success-border: #bbf7d0;
|
|
127
|
+
--snw-error: #dc2626;
|
|
128
|
+
--snw-error-bg: #fef2f2;
|
|
129
|
+
--snw-error-border: #fecaca;
|
|
130
|
+
--snw-radius: 12px;
|
|
131
|
+
--snw-radius-sm: 8px;
|
|
132
|
+
--snw-font: system-ui, -apple-system, sans-serif;
|
|
133
|
+
}
|
|
134
|
+
/* ================================================================= */
|
|
135
|
+
|
|
136
|
+
.snw-pricing-container {
|
|
137
|
+
max-width: 1200px;
|
|
138
|
+
margin: 0 auto;
|
|
139
|
+
padding: 2rem 1rem;
|
|
140
|
+
font-family: var(--snw-font);
|
|
141
|
+
}
|
|
142
|
+
.snw-pricing-header {
|
|
143
|
+
text-align: center;
|
|
144
|
+
margin-bottom: 2rem;
|
|
145
|
+
}
|
|
146
|
+
.snw-pricing-title {
|
|
147
|
+
font-size: 2rem;
|
|
148
|
+
font-weight: 700;
|
|
149
|
+
color: var(--snw-text);
|
|
150
|
+
margin: 0 0 0.5rem 0;
|
|
151
|
+
}
|
|
152
|
+
.snw-pricing-subtitle {
|
|
153
|
+
color: var(--snw-text-muted);
|
|
154
|
+
font-size: 1.1rem;
|
|
155
|
+
margin: 0;
|
|
156
|
+
}
|
|
157
|
+
.snw-interval-toggle {
|
|
158
|
+
display: flex;
|
|
159
|
+
justify-content: center;
|
|
160
|
+
gap: 0.5rem;
|
|
161
|
+
margin-bottom: 2rem;
|
|
162
|
+
}
|
|
163
|
+
.snw-interval-btn {
|
|
164
|
+
padding: 0.5rem 1rem;
|
|
165
|
+
border: 1px solid var(--snw-border);
|
|
166
|
+
background: var(--snw-background);
|
|
167
|
+
border-radius: 6px;
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
font-size: 0.9rem;
|
|
170
|
+
transition: all 0.15s;
|
|
171
|
+
}
|
|
172
|
+
.snw-interval-btn:hover {
|
|
173
|
+
border-color: var(--snw-primary);
|
|
174
|
+
}
|
|
175
|
+
.snw-interval-btn.active {
|
|
176
|
+
background: var(--snw-primary);
|
|
177
|
+
border-color: var(--snw-primary);
|
|
178
|
+
color: white;
|
|
179
|
+
}
|
|
180
|
+
.snw-pricing-grid {
|
|
181
|
+
display: grid;
|
|
182
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
183
|
+
gap: 1.5rem;
|
|
184
|
+
}
|
|
185
|
+
.snw-pricing-card {
|
|
186
|
+
border: 1px solid var(--snw-border);
|
|
187
|
+
border-radius: var(--snw-radius);
|
|
188
|
+
padding: 1.5rem;
|
|
189
|
+
background: var(--snw-background);
|
|
190
|
+
transition: all 0.2s;
|
|
191
|
+
display: flex;
|
|
192
|
+
flex-direction: column;
|
|
193
|
+
position: relative;
|
|
194
|
+
}
|
|
195
|
+
.snw-pricing-card:hover {
|
|
196
|
+
border-color: var(--snw-primary);
|
|
197
|
+
box-shadow: 0 4px 12px color-mix(in srgb, var(--snw-primary) 10%, transparent);
|
|
198
|
+
}
|
|
199
|
+
.snw-pricing-card.current {
|
|
200
|
+
border-color: var(--snw-primary);
|
|
201
|
+
border-width: 2px;
|
|
202
|
+
}
|
|
203
|
+
.snw-plan-name {
|
|
204
|
+
font-size: 1.25rem;
|
|
205
|
+
font-weight: 600;
|
|
206
|
+
color: var(--snw-text);
|
|
207
|
+
margin: 0 0 0.25rem 0;
|
|
208
|
+
}
|
|
209
|
+
.snw-plan-description {
|
|
210
|
+
color: var(--snw-text-muted);
|
|
211
|
+
font-size: 0.9rem;
|
|
212
|
+
margin: 0 0 1rem 0;
|
|
213
|
+
}
|
|
214
|
+
.snw-plan-price {
|
|
215
|
+
font-size: 2.5rem;
|
|
216
|
+
font-weight: 700;
|
|
217
|
+
color: var(--snw-text);
|
|
218
|
+
margin: 0;
|
|
219
|
+
}
|
|
220
|
+
.snw-plan-interval {
|
|
221
|
+
color: var(--snw-text-muted);
|
|
222
|
+
font-size: 0.9rem;
|
|
223
|
+
}
|
|
224
|
+
.snw-plan-features {
|
|
225
|
+
list-style: none;
|
|
226
|
+
padding: 0;
|
|
227
|
+
margin: 1.5rem 0;
|
|
228
|
+
flex-grow: 1;
|
|
229
|
+
}
|
|
230
|
+
.snw-plan-feature {
|
|
231
|
+
padding: 0.4rem 0;
|
|
232
|
+
color: var(--snw-text-secondary);
|
|
233
|
+
font-size: 0.95rem;
|
|
234
|
+
display: flex;
|
|
235
|
+
align-items: center;
|
|
236
|
+
gap: 0.5rem;
|
|
237
|
+
}
|
|
238
|
+
.snw-plan-feature::before {
|
|
239
|
+
content: "✓";
|
|
240
|
+
color: var(--snw-primary);
|
|
241
|
+
font-weight: bold;
|
|
242
|
+
}
|
|
243
|
+
.snw-plan-btn {
|
|
244
|
+
width: 100%;
|
|
245
|
+
padding: 0.75rem 1rem;
|
|
246
|
+
border: none;
|
|
247
|
+
border-radius: var(--snw-radius-sm);
|
|
248
|
+
font-size: 1rem;
|
|
249
|
+
font-weight: 500;
|
|
250
|
+
cursor: pointer;
|
|
251
|
+
transition: all 0.15s;
|
|
252
|
+
}
|
|
253
|
+
.snw-plan-btn:disabled {
|
|
254
|
+
opacity: 0.7;
|
|
255
|
+
cursor: not-allowed;
|
|
256
|
+
}
|
|
257
|
+
.snw-plan-btn.primary {
|
|
258
|
+
background: var(--snw-primary);
|
|
259
|
+
color: white;
|
|
260
|
+
}
|
|
261
|
+
.snw-plan-btn.primary:hover:not(:disabled) {
|
|
262
|
+
background: var(--snw-primary-hover);
|
|
263
|
+
}
|
|
264
|
+
.snw-plan-btn.secondary {
|
|
265
|
+
background: var(--snw-background-secondary);
|
|
266
|
+
color: var(--snw-text-secondary);
|
|
267
|
+
}
|
|
268
|
+
.snw-plan-btn.secondary:hover:not(:disabled) {
|
|
269
|
+
background: var(--snw-border);
|
|
270
|
+
}
|
|
271
|
+
.snw-current-badge {
|
|
272
|
+
position: absolute;
|
|
273
|
+
top: -0.65rem;
|
|
274
|
+
left: 1.25rem;
|
|
275
|
+
background: var(--snw-primary);
|
|
276
|
+
color: white;
|
|
277
|
+
font-size: 0.7rem;
|
|
278
|
+
font-weight: 600;
|
|
279
|
+
padding: 0.25rem 0.75rem;
|
|
280
|
+
border-radius: 9999px;
|
|
281
|
+
text-transform: uppercase;
|
|
282
|
+
letter-spacing: 0.025em;
|
|
283
|
+
}
|
|
284
|
+
.snw-error {
|
|
285
|
+
background: var(--snw-error-bg);
|
|
286
|
+
border: 1px solid var(--snw-error-border);
|
|
287
|
+
color: var(--snw-error);
|
|
288
|
+
padding: 0.75rem 1rem;
|
|
289
|
+
border-radius: var(--snw-radius-sm);
|
|
290
|
+
margin-bottom: 1rem;
|
|
291
|
+
text-align: center;
|
|
292
|
+
}
|
|
293
|
+
.snw-success {
|
|
294
|
+
background: var(--snw-success-bg);
|
|
295
|
+
border: 1px solid var(--snw-success-border);
|
|
296
|
+
color: var(--snw-success);
|
|
297
|
+
padding: 0.75rem 1rem;
|
|
298
|
+
border-radius: var(--snw-radius-sm);
|
|
299
|
+
margin-bottom: 1rem;
|
|
300
|
+
text-align: center;
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
gap: 0.5rem;
|
|
305
|
+
animation: snw-fade-in 0.3s ease-out;
|
|
306
|
+
}
|
|
307
|
+
.snw-success-icon {
|
|
308
|
+
width: 20px;
|
|
309
|
+
height: 20px;
|
|
310
|
+
background: var(--snw-success);
|
|
311
|
+
border-radius: 50%;
|
|
312
|
+
display: flex;
|
|
313
|
+
align-items: center;
|
|
314
|
+
justify-content: center;
|
|
315
|
+
color: white;
|
|
316
|
+
font-size: 12px;
|
|
317
|
+
flex-shrink: 0;
|
|
318
|
+
}
|
|
319
|
+
@keyframes snw-fade-in {
|
|
320
|
+
from { opacity: 0; transform: translateY(-10px); }
|
|
321
|
+
to { opacity: 1; transform: translateY(0); }
|
|
322
|
+
}
|
|
323
|
+
.snw-loading-spinner {
|
|
324
|
+
display: inline-block;
|
|
325
|
+
width: 16px;
|
|
326
|
+
height: 16px;
|
|
327
|
+
border: 2px solid currentColor;
|
|
328
|
+
border-right-color: transparent;
|
|
329
|
+
border-radius: 50%;
|
|
330
|
+
animation: snw-spin 0.6s linear infinite;
|
|
331
|
+
margin-right: 0.5rem;
|
|
332
|
+
}
|
|
333
|
+
@keyframes snw-spin {
|
|
334
|
+
to { transform: rotate(360deg); }
|
|
335
|
+
}
|
|
336
|
+
@media (max-width: 768px) {
|
|
337
|
+
.snw-pricing-grid {
|
|
338
|
+
grid-template-columns: 1fr;
|
|
339
|
+
}
|
|
340
|
+
.snw-pricing-title {
|
|
341
|
+
font-size: 1.5rem;
|
|
342
|
+
}
|
|
343
|
+
.snw-plan-price {
|
|
344
|
+
font-size: 2rem;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
`}</style>
|
|
348
|
+
|
|
349
|
+
<div className="snw-pricing-container">
|
|
350
|
+
<div className="snw-pricing-header">
|
|
351
|
+
<h1 className="snw-pricing-title">Choose your plan</h1>
|
|
352
|
+
<p className="snw-pricing-subtitle">
|
|
353
|
+
Start free, upgrade when you need more
|
|
354
|
+
</p>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
{hasMultipleIntervals && (
|
|
358
|
+
<div className="snw-interval-toggle">
|
|
359
|
+
<button
|
|
360
|
+
className={`snw-interval-btn ${interval === "month" ? "active" : ""}`}
|
|
361
|
+
onClick={() => setInterval("month")}
|
|
362
|
+
>
|
|
363
|
+
Monthly
|
|
364
|
+
</button>
|
|
365
|
+
<button
|
|
366
|
+
className={`snw-interval-btn ${interval === "year" ? "active" : ""}`}
|
|
367
|
+
onClick={() => setInterval("year")}
|
|
368
|
+
>
|
|
369
|
+
Yearly
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
373
|
+
|
|
374
|
+
{countdown !== null && (
|
|
375
|
+
<div className="snw-success">
|
|
376
|
+
<span className="snw-success-icon">✓</span>
|
|
377
|
+
<span>Plan updated! Redirecting in {countdown}...</span>
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{error && <div className="snw-error">{error}</div>}
|
|
382
|
+
|
|
383
|
+
<div className="snw-pricing-grid">
|
|
384
|
+
{plans.map((plan) => {
|
|
385
|
+
const planId = getPlanId(plan);
|
|
386
|
+
const price = getPrice(plan, interval);
|
|
387
|
+
const isCurrent = currentPlanId === planId;
|
|
388
|
+
const isLoading = loadingPlanId === planId;
|
|
389
|
+
const isManageLoading = loadingPlanId === "manage";
|
|
390
|
+
const isFree = !price || price.amount === 0;
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<div
|
|
394
|
+
key={planId}
|
|
395
|
+
className={`snw-pricing-card ${isCurrent ? "current" : ""}`}
|
|
396
|
+
>
|
|
397
|
+
{isCurrent && <span className="snw-current-badge">Current Plan</span>}
|
|
398
|
+
<h2 className="snw-plan-name">{plan.name}</h2>
|
|
399
|
+
{plan.description && (
|
|
400
|
+
<p className="snw-plan-description">{plan.description}</p>
|
|
401
|
+
)}
|
|
402
|
+
<p className="snw-plan-price">
|
|
403
|
+
{isFree || !price
|
|
404
|
+
? "Free"
|
|
405
|
+
: formatPrice(price.amount, price.currency)}
|
|
406
|
+
{price && !isFree && price.interval !== "one_time" && (
|
|
407
|
+
<span className="snw-plan-interval">/{price.interval}</span>
|
|
408
|
+
)}
|
|
409
|
+
</p>
|
|
410
|
+
|
|
411
|
+
{plan.credits && (
|
|
412
|
+
<ul className="snw-plan-features">
|
|
413
|
+
{Object.entries(plan.credits).map(([type, config]) => (
|
|
414
|
+
<li key={type} className="snw-plan-feature">
|
|
415
|
+
{config.allocation.toLocaleString()} {config.displayName || type}
|
|
416
|
+
{config.onRenewal === "add"
|
|
417
|
+
? " (accumulates)"
|
|
418
|
+
: `/${interval === "year" ? "year" : "month"}`}
|
|
419
|
+
</li>
|
|
420
|
+
))}
|
|
421
|
+
</ul>
|
|
422
|
+
)}
|
|
423
|
+
|
|
424
|
+
{isCurrent ? (
|
|
425
|
+
<button
|
|
426
|
+
className="snw-plan-btn secondary"
|
|
427
|
+
onClick={handleManage}
|
|
428
|
+
disabled={isManageLoading}
|
|
429
|
+
>
|
|
430
|
+
{isManageLoading && <span className="snw-loading-spinner" />}
|
|
431
|
+
Manage Subscription
|
|
432
|
+
</button>
|
|
433
|
+
) : (
|
|
434
|
+
<button
|
|
435
|
+
className="snw-plan-btn primary"
|
|
436
|
+
onClick={() => handleCheckout(plan)}
|
|
437
|
+
disabled={isLoading || !!loadingPlanId}
|
|
438
|
+
>
|
|
439
|
+
{isLoading && <span className="snw-loading-spinner" />}
|
|
440
|
+
{isFree ? "Get Started" : currentPlanId ? "Switch Plan" : "Subscribe"}
|
|
441
|
+
</button>
|
|
442
|
+
)}
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
})}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
// app/api/stripe/[...all]/route.ts
|
|
2
|
-
import {
|
|
3
|
-
import type { Stripe } from "stripe";
|
|
4
|
-
import billingConfig from "../../../../billing.config";
|
|
2
|
+
import { billing } from "@/lib/billing";
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
4
|
+
// TODO: Import your auth library
|
|
5
|
+
// import { auth } from "@clerk/nextjs/server";
|
|
6
|
+
// import { getServerSession } from "next-auth";
|
|
7
|
+
|
|
8
|
+
export const POST = billing.createHandler({
|
|
9
|
+
// REQUIRED: Resolve the authenticated user from the request
|
|
10
|
+
resolveUser: async () => {
|
|
11
|
+
// Clerk:
|
|
12
|
+
// const { userId } = await auth();
|
|
13
|
+
// return userId ? { id: userId } : null;
|
|
14
|
+
|
|
15
|
+
// NextAuth:
|
|
16
|
+
// const session = await getServerSession();
|
|
17
|
+
// return session?.user?.id ? { id: session.user.id } : null;
|
|
18
|
+
|
|
19
|
+
return null; // TODO: Replace with your auth
|
|
19
20
|
},
|
|
21
|
+
|
|
22
|
+
// OPTIONAL: Resolve org for team/org billing
|
|
23
|
+
// resolveOrg: async () => {
|
|
24
|
+
// const session = await getSession();
|
|
25
|
+
// return session.currentOrgId ?? null;
|
|
26
|
+
// },
|
|
20
27
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Billing } from "stripe-no-webhooks";
|
|
2
|
+
import billingConfig from "../billing.config";
|
|
3
|
+
// import type { Stripe } from "stripe";
|
|
4
|
+
|
|
5
|
+
// Initialize once, use everywhere (for credits/subscriptions API access)
|
|
6
|
+
// If you only need the webhook handler, you can skip this file and use
|
|
7
|
+
// createHandler() directly in your API route.
|
|
8
|
+
export const billing = new Billing({
|
|
9
|
+
billingConfig,
|
|
10
|
+
// Keys and database URL are read from environment variables by default:
|
|
11
|
+
// - STRIPE_SECRET_KEY
|
|
12
|
+
// - STRIPE_WEBHOOK_SECRET
|
|
13
|
+
// - DATABASE_URL
|
|
14
|
+
|
|
15
|
+
// OPTIONAL: Add callbacks for subscription/credit events
|
|
16
|
+
// callbacks: {
|
|
17
|
+
// onSubscriptionCreated: async (subscription: Stripe.Subscription) => {
|
|
18
|
+
// console.log("New subscription:", subscription.id);
|
|
19
|
+
// },
|
|
20
|
+
// onSubscriptionCancelled: async (subscription: Stripe.Subscription) => {
|
|
21
|
+
// console.log("Subscription cancelled:", subscription.id);
|
|
22
|
+
// },
|
|
23
|
+
// onCreditsGranted: ({ userId, creditType, amount }) => {
|
|
24
|
+
// console.log(`Granted ${amount} ${creditType} to ${userId}`);
|
|
25
|
+
// },
|
|
26
|
+
// },
|
|
27
|
+
});
|
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
// pages/api/stripe/[...all].ts
|
|
2
|
-
import {
|
|
2
|
+
import { billing } from "@/lib/billing";
|
|
3
3
|
import type { NextApiRequest, NextApiResponse } from "next";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
4
|
+
|
|
5
|
+
// TODO: Import your auth library
|
|
6
|
+
// import { getAuth } from "@clerk/nextjs/server";
|
|
7
|
+
// import { getServerSession } from "next-auth";
|
|
8
|
+
|
|
9
|
+
const handler = billing.createHandler({
|
|
10
|
+
// REQUIRED: Resolve the authenticated user from the request
|
|
11
|
+
resolveUser: async () => {
|
|
12
|
+
// Clerk:
|
|
13
|
+
// const { userId } = getAuth(req);
|
|
14
|
+
// return userId ? { id: userId } : null;
|
|
15
|
+
|
|
16
|
+
// NextAuth:
|
|
17
|
+
// const session = await getServerSession(req, res);
|
|
18
|
+
// return session?.user?.id ? { id: session.user.id } : null;
|
|
19
|
+
|
|
20
|
+
return null; // TODO: Replace with your auth
|
|
20
21
|
},
|
|
22
|
+
|
|
23
|
+
// OPTIONAL: Resolve org for team/org billing
|
|
24
|
+
// resolveOrg: async () => {
|
|
25
|
+
// const session = await getSession(req);
|
|
26
|
+
// return session.currentOrgId ?? null;
|
|
27
|
+
// },
|
|
21
28
|
});
|
|
22
29
|
|
|
23
30
|
// Disable body parsing, we need the raw body for webhook verification
|
|
@@ -34,7 +41,7 @@ export default async function stripeHandler(
|
|
|
34
41
|
// Convert NextApiRequest to Request for the handler
|
|
35
42
|
const body = await new Promise<string>((resolve) => {
|
|
36
43
|
let data = "";
|
|
37
|
-
req.on("data", (chunk) => (data += chunk));
|
|
44
|
+
req.on("data", (chunk: string) => (data += chunk));
|
|
38
45
|
req.on("end", () => resolve(data));
|
|
39
46
|
});
|
|
40
47
|
|