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:
54
package-lock.json
generated
54
package-lock.json
generated
@@ -26,6 +26,7 @@
|
|||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"stripe": "^20.0.0",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3925,7 +3926,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -3939,7 +3939,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -4250,7 +4249,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -4362,7 +4360,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4372,7 +4369,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4410,7 +4406,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -5058,7 +5053,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -5119,7 +5113,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -5153,7 +5146,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -5241,7 +5233,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5313,7 +5304,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -5342,7 +5332,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -6343,7 +6332,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -6555,7 +6543,6 @@
|
|||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -6862,6 +6849,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
|
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -7305,7 +7307,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7325,7 +7326,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7342,7 +7342,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7361,7 +7360,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7553,6 +7551,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "20.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.0.0.tgz",
|
||||||
|
"integrity": "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"qs": "^6.11.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"stripe": "^20.0.0",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
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
|
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: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
|
|||||||
Reference in New Issue
Block a user