diff --git a/.gitignore b/.gitignore index 1437c53..c9cabe4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ yarn-error.log* .env.development.local .env.test.local .env.production.local +.env # vercel -.vercel +.vercel \ No newline at end of file diff --git a/components/Dashboard.js b/components/Dashboard.js index cdcd61a..0acebbb 100644 --- a/components/Dashboard.js +++ b/components/Dashboard.js @@ -7,8 +7,8 @@ function with your new elements. import { useEffect, useState } from "react"; import Avatar from "./Avatar"; -import MyModal from "./MyModal"; import { supabase } from "../utils/supabaseClient"; +import { toast } from "react-toastify"; export default function Account({ session }) { const [loading, setLoading] = useState(true); @@ -71,6 +71,7 @@ export default function Account({ session }) { alert(error.message); } finally { setLoading(false); + toast.success("Your profile has been updated") } } diff --git a/components/Layout.js b/components/Layout.js index 116aeae..7b8c4fd 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -23,7 +23,7 @@ const Layout = (props) => { const toastStyle = { //Style your toast elements here - success: "bg-secondary", + success: "bg-accent", error: "bg-red-600", info: "bg-gray-600", warning: "bg-orange-400", diff --git a/components/Pricing.js b/components/Pricing.js index 56e19e4..04762f2 100644 --- a/components/Pricing.js +++ b/components/Pricing.js @@ -3,12 +3,50 @@ This is the pricing component. You can switch between flat payment or subscription by setting the flat variable. */ +import { useEffect, useState } from "react"; + +import { Auth } from "@supabase/ui"; import { Switch } from "@headlessui/react"; -import { useState } from "react"; +import axios from "axios"; +import getStripe from "utils/stripe"; +import { supabase } from "utils/supabaseClient"; const Pricing = () => { const [enabled, setEnabled] = useState(false); - const flat = false; + const [loading, setLoading] = useState(false); + const { user, session } = Auth.useUser(); + const [customerId, setCustomerId] = useState(null); + + useEffect(() => { + if (user) { + supabase + .from("profiles") + .select(`customerId`) + .eq("id", user.id) + .single() + .then( + (result) => + // Even if null, it returns something so we check if the customerId is > 1 to be sure it exist + result.data.customerId.length > 1 && + setCustomerId(result.data.customerId) + ); + } + }, [user]); + const prices = { + personal: { + monthly: "price_1J5q2yDMjD0UnVmMXzEWYDnl", + anually: "price_1J5q45DMjD0UnVmMQxXHKGAv", + }, + team: { + monthly: "price_1J5q3GDMjD0UnVmMlHc5Eedq", + anually: "price_1J5q8zDMjD0UnVmMqsngM91X", + }, + pro: { + monthly: "price_1J5q3TDMjD0UnVmMJKX3nkDq", + anually: "price_1J5q9VDMjD0UnVmMIQtVDSZ9", + }, + }; + const flat = false; // Switch between subscription system or flat prices const pricing = { monthly: { personal: "$5/mo", @@ -26,6 +64,40 @@ const Pricing = () => { pro: "€149", }, }; + + const handleSubmit = async (e, priceId) => { + e.preventDefault(); + setLoading(true); + + // Create a Checkout Session. This will redirect the user to the Stripe website for the payment. + + axios + .post("/api/stripe/create-checkout-session", { + priceId: priceId, + email: user.email, + customerId: customerId, + userId: user.id, + tokenId: session.access_token, + }) + .then((result) => { + stripe + .redirectToCheckout({ + sessionId: result.data.sessionId, + }) + .then((result) => { + console.log(result); + }); + }); + + // Redirect to Checkout. + const stripe = await getStripe(); + + // If `redirectToCheckout` fails due to a browser or network + // error, display the localized error message to your customer + // using `error.message`. + + setLoading(false); + }; return (
@@ -88,7 +160,16 @@ const Pricing = () => {
- +
@@ -120,7 +201,16 @@ const Pricing = () => {
- +
@@ -152,7 +242,16 @@ const Pricing = () => {
- +
diff --git a/env.local.example b/env.local.example index a3f78cc..a72c36f 100644 --- a/env.local.example +++ b/env.local.example @@ -1,6 +1,10 @@ NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY +SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY NEXT_PUBLIC_TITLE="SupaNexTail" SENDGRID_SECRET=YOUR_SENGRID_SECRET SENDGRID_MAILTO=YOUR_RECIPIENT -SENDGRID_MAILFROM=YOUR_VERIFIED_SENDER \ No newline at end of file +SENDGRID_MAILFROM=YOUR_VERIFIED_SENDER +STRIPE_SECRET=YOUR_SECRET_STRIPE_KEY +NEXT_PUBLIC_STRIPE_PUBLIC_KEY=YOUR_PUBLIC_STRIPE_KEY +STRIPE_WEBHOOK=YOUR_STRIPE_WEBHOOK \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7d77b27..ab86d00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,30 @@ { - "name": "with-tailwindcss", - "version": "0.1.0", + "name": "supanextail", + "version": "0.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "with-tailwindcss", - "version": "0.1.0", + "version": "0.4.0", "dependencies": { "@headlessui/react": "^1.2.0", "@sendgrid/mail": "^7.4.4", + "@stripe/stripe-js": "^1.15.1", "@supabase/supabase-js": "^1.15.0", "@supabase/ui": "^0.27.3", "axios": "^0.21.1", + "cors": "^2.8.5", "daisyui": "^1.3.4", + "express-rate-limit": "^5.2.6", + "micro": "^9.3.4", "next": "^11.0.0", "next-seo": "^4.24.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-feather": "^2.0.9", "react-icons": "^4.2.0", - "react-toastify": "^7.0.4" + "react-toastify": "^7.0.4", + "stripe": "^8.156.0" }, "devDependencies": { "autoprefixer": "^10.0.4", @@ -300,6 +304,11 @@ "node": "6.* || 8.* || >=10.*" } }, + "node_modules/@stripe/stripe-js": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.15.1.tgz", + "integrity": "sha512-yJiDGutlwu25iajCy51VRJeoH3UMs+s5qVIDGfmPUuFpZ+F6AJ9g9EFrsBNvHxAGBahQFMLlBdzlCVydhGp6tg==" + }, "node_modules/@supabase/gotrue-js": { "version": "1.16.5", "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-1.16.5.tgz", @@ -895,6 +904,14 @@ "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=" }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", @@ -913,6 +930,18 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cosmiconfig": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", @@ -1346,6 +1375,11 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/express-rate-limit": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.6.tgz", + "integrity": "sha512-nE96xaxGfxiS5jP3tD3kIW1Jg9yQgX0rXCs3rCkZtmbWHEGyotwaezkLj7bnB41Z0uaOLM8W4AX6qHao4IZ2YA==" + }, "node_modules/ext": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", @@ -1953,6 +1987,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-string": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz", @@ -2185,6 +2227,90 @@ "node": ">= 8" } }, + "node_modules/micro": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/micro/-/micro-9.3.4.tgz", + "integrity": "sha512-smz9naZwTG7qaFnEZ2vn248YZq9XR+XoOH3auieZbkhDL4xLOxiE+KqG8qqnBeKfXA9c1uEFGCxPN1D+nT6N7w==", + "dependencies": { + "arg": "4.1.0", + "content-type": "1.0.4", + "is-stream": "1.1.0", + "raw-body": "2.3.2" + }, + "bin": { + "micro": "bin/micro.js" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/micro/node_modules/arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==" + }, + "node_modules/micro/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/micro/node_modules/depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micro/node_modules/http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "dependencies": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": ">= 1.3.1 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micro/node_modules/iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/micro/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "node_modules/micro/node_modules/raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "dependencies": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/micro/node_modules/setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + }, "node_modules/micromatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", @@ -3034,6 +3160,20 @@ "purgecss": "bin/purgecss.js" } }, + "node_modules/qs": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystring": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", @@ -3393,6 +3533,19 @@ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==" }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -3522,6 +3675,18 @@ "node": ">=8" } }, + "node_modules/stripe": { + "version": "8.156.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-8.156.0.tgz", + "integrity": "sha512-q+bixlhaxnSI/Htk/iB1i5LhuZ557hL0pFgECBxQNhso1elxIsOsPOIXEuo3tSLJEb8CJSB7t/+Fyq6KP69tAQ==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.6.0" + }, + "engines": { + "node": "^8.1 || >=10.*" + } + }, "node_modules/styled-jsx": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-3.3.2.tgz", @@ -3905,6 +4070,14 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -4241,6 +4414,11 @@ "@sendgrid/helpers": "^7.4.3" } }, + "@stripe/stripe-js": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.15.1.tgz", + "integrity": "sha512-yJiDGutlwu25iajCy51VRJeoH3UMs+s5qVIDGfmPUuFpZ+F6AJ9g9EFrsBNvHxAGBahQFMLlBdzlCVydhGp6tg==" + }, "@supabase/gotrue-js": { "version": "1.16.5", "resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-1.16.5.tgz", @@ -4730,6 +4908,11 @@ "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=" }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, "convert-source-map": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", @@ -4750,6 +4933,15 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cosmiconfig": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", @@ -5117,6 +5309,11 @@ "safe-buffer": "^5.1.1" } }, + "express-rate-limit": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.2.6.tgz", + "integrity": "sha512-nE96xaxGfxiS5jP3tD3kIW1Jg9yQgX0rXCs3rCkZtmbWHEGyotwaezkLj7bnB41Z0uaOLM8W4AX6qHao4IZ2YA==" + }, "ext": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", @@ -5533,6 +5730,11 @@ "has-symbols": "^1.0.2" } }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, "is-string": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz", @@ -5711,6 +5913,71 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, + "micro": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/micro/-/micro-9.3.4.tgz", + "integrity": "sha512-smz9naZwTG7qaFnEZ2vn248YZq9XR+XoOH3auieZbkhDL4xLOxiE+KqG8qqnBeKfXA9c1uEFGCxPN1D+nT6N7w==", + "requires": { + "arg": "4.1.0", + "content-type": "1.0.4", + "is-stream": "1.1.0", + "raw-body": "2.3.2" + }, + "dependencies": { + "arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==" + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "depd": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz", + "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k=" + }, + "http-errors": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", + "integrity": "sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY=", + "requires": { + "depd": "1.1.1", + "inherits": "2.0.3", + "setprototypeof": "1.0.3", + "statuses": ">= 1.3.1 < 2" + } + }, + "iconv-lite": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz", + "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==" + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "raw-body": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz", + "integrity": "sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k=", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.2", + "iconv-lite": "0.4.19", + "unpipe": "1.0.0" + } + }, + "setprototypeof": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz", + "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ=" + } + } + }, "micromatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", @@ -6371,6 +6638,14 @@ "postcss-selector-parser": "^6.0.2" } }, + "qs": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "requires": { + "side-channel": "^1.0.4" + } + }, "querystring": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", @@ -6628,6 +6903,16 @@ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.2.tgz", "integrity": "sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==" }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -6738,6 +7023,15 @@ "ansi-regex": "^5.0.0" } }, + "stripe": { + "version": "8.156.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-8.156.0.tgz", + "integrity": "sha512-q+bixlhaxnSI/Htk/iB1i5LhuZ557hL0pFgECBxQNhso1elxIsOsPOIXEuo3tSLJEb8CJSB7t/+Fyq6KP69tAQ==", + "requires": { + "@types/node": ">=8.1.0", + "qs": "^6.6.0" + } + }, "styled-jsx": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-3.3.2.tgz", @@ -7038,6 +7332,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, "vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", diff --git a/package.json b/package.json index 5cad476..71a0c49 100644 --- a/package.json +++ b/package.json @@ -10,17 +10,22 @@ "dependencies": { "@headlessui/react": "^1.2.0", "@sendgrid/mail": "^7.4.4", + "@stripe/stripe-js": "^1.15.1", "@supabase/supabase-js": "^1.15.0", "@supabase/ui": "^0.27.3", "axios": "^0.21.1", + "cors": "^2.8.5", "daisyui": "^1.3.4", + "express-rate-limit": "^5.2.6", + "micro": "^9.3.4", "next": "^11.0.0", "next-seo": "^4.24.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-feather": "^2.0.9", "react-icons": "^4.2.0", - "react-toastify": "^7.0.4" + "react-toastify": "^7.0.4", + "stripe": "^8.156.0" }, "devDependencies": { "autoprefixer": "^10.0.4", diff --git a/pages/api/stripe/create-checkout-session.js b/pages/api/stripe/create-checkout-session.js new file mode 100644 index 0000000..70d6981 --- /dev/null +++ b/pages/api/stripe/create-checkout-session.js @@ -0,0 +1,83 @@ +import Cors from "cors"; +import initMiddleware from "utils/init-middleware"; + +const rateLimit = require("express-rate-limit"); + +const cors = initMiddleware( + Cors({ + methods: ["POST"], + }) +); + +const limiter = initMiddleware( + rateLimit({ + windowMs: 30000, // 30sec + max: 4, // Max 4 request per 30 sec + }) +); +// Set your secret key. Remember to switch to your live secret key in production. +// See your keys here: https://dashboard.stripe.com/apikeys +const stripe = require("stripe")(process.env.STRIPE_SECRET); + +export default async function handler(req, res) { + await cors(req, res); + await limiter(req, res); + if (req.method === "POST") { + const priceId = req.body.priceId; + + // See https://stripe.com/docs/api/checkout/sessions/create + // for additional parameters to pass. + try { + const session = req.body.customerId + ? await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + client_reference_id: req.body.userId, + metadata: { token: req.body.tokenId, priceId: req.body.priceId }, + customer: req.body.customerId, + line_items: [ + { + price: priceId, + // For metered billing, do not pass quantity + quantity: 1, + }, + ], + // {CHECKOUT_SESSION_ID} is a string literal; do not change it! + // the actual Session ID is returned in the query parameter when your customer + // is redirected to the success page. + success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${req.headers.origin}/dashboard?session_id=canceled`, + }) + : await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + customer_email: req.body.email, + client_reference_id: req.body.userId, + metadata: { token: req.body.tokenId, priceId: req.body.priceId }, + line_items: [ + { + price: priceId, + // For metered billing, do not pass quantity + quantity: 1, + }, + ], + // {CHECKOUT_SESSION_ID} is a string literal; do not change it! + // the actual Session ID is returned in the query parameter when your customer + // is redirected to the success page. + success_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${req.headers.origin}/dashboard?session_id={CHECKOUT_SESSION_ID}`, + }); + + res.send({ + sessionId: session.id, + }); + } catch (e) { + res.status(400); + return res.send({ + error: { + message: e.message, + }, + }); + } + } +} diff --git a/pages/api/stripe/customer-portal.js b/pages/api/stripe/customer-portal.js new file mode 100644 index 0000000..dc3e591 --- /dev/null +++ b/pages/api/stripe/customer-portal.js @@ -0,0 +1,33 @@ +import Cors from "cors"; +import initMiddleware from "utils/init-middleware"; +const rateLimit = require("express-rate-limit"); + +const cors = initMiddleware( + Cors({ + methods: ["POST", "PUT"], + }) +); + +const limiter = initMiddleware( + rateLimit({ + windowMs: 30000, // 30sec + max: 150, // Max 4 request per 30 sec + }) +); +// Set your secret key. Remember to switch to your live secret key in production. +// See your keys here: https://dashboard.stripe.com/apikeys +const stripe = require("stripe")(process.env.STRIPE_SECRET); + +export default async function handler(req, res) { + await cors(req, res); + await limiter(req, res); + if (req.method === "POST") { + const returnUrl = `${req.headers.origin}/dashboard`; + + const portalsession = await stripe.billingPortal.sessions.create({ + customer: req.query.customerId, + return_url: returnUrl, + }); + res.status(200).send({ url: portalsession.url }); + } +} diff --git a/pages/api/stripe/stripe-webhook.js b/pages/api/stripe/stripe-webhook.js new file mode 100644 index 0000000..633eff5 --- /dev/null +++ b/pages/api/stripe/stripe-webhook.js @@ -0,0 +1,135 @@ +import Cors from "cors"; +import { buffer } from "micro"; +import { createClient } from "@supabase/supabase-js"; +import initMiddleware from "utils/init-middleware"; +const rateLimit = require("express-rate-limit"); + +export const config = { + api: { + bodyParser: false, + }, +}; + +// Initialize the cors middleware -> Allow the browser extension to create lists +const cors = initMiddleware( + Cors({ + methods: ["POST", "HEAD"], + }) +); + +// Init Supabase Admin + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.SUPABASE_ADMIN_KEY +); + +// Rate limiter : The user can only create one list every 20 seconds (avoid spam) + +const limiter = initMiddleware( + rateLimit({ + windowMs: 30000, // 30sec + max: 150, // Max 150 request per 30 sec + }) +); +// Set your secret key. Remember to switch to your live secret key in production. +// See your keys here: https://dashboard.stripe.com/apikeys +const stripe = require("stripe")(process.env.STRIPE_SECRET); + +export default async function handler(req, res) { + await cors(req, res); + await limiter(req, res); + stripe.setMaxNetworkRetries(2); + + if (req.method === "POST") { + // Retrieve the event by verifying the signature using the raw body and secret. + let event; + const buf = await buffer(req); + + try { + event = stripe.webhooks.constructEvent( + buf, + req.headers["stripe-signature"], + process.env.STRIPE_WEBHOOK + ); + } catch (err) { + console.log(err); + console.log(`⚠️ Webhook signature verification failed.`); + console.log( + `⚠️ Check the env file and enter the correct webhook secret.` + ); + return res.send(400); + } + // Extract the object from the event. + const dataObject = event.data.object; + + // Handle the event + // Review important events for Billing webhooks + // https://stripe.com/docs/billing/webhooks + // Remove comment to see the various objects sent for this sample + switch (event.type) { + case "checkout.session.completed": + let { data: subscriptions, error } = await supabase + .from("subscriptions") + .select("*") + .eq("id", dataObject.client_reference_id); + + if (subscriptions.length == 0) { + const { data, error } = await supabase + .from("profiles") + .update({ customerId: dataObject.customer }) + .eq("id", dataObject.client_reference_id); + if (error) console.log(error); + + await supabase + .from("subscriptions") + .insert([ + { + id: dataObject.client_reference_id, + customer_id: dataObject.customer, + paid_user: true, + plan: dataObject.metadata.priceId, + }, + ]) + .then() + .catch((err) => console.log(err)); + } else if (subscriptions.length > 0) { + await supabase + .from("subscriptions") + .update({ + customer_id: dataObject.customer, + paid_user: true, + plan: dataObject.metadata.priceId, + }) + .eq("id", dataObject.client_reference_id) + .then() + .catch((err) => console.log(err)); + } + break; + case "customer.subscription.deleted": + await supabase + .from("subscriptions") + .update({ paid_user: false }) + .eq("customer_id", dataObject.customer) + .then() + .catch((err) => console.log(err)); + break; + case "invoice.payment_failed": + // If the payment fails or the customer does not have a valid payment method, + // an invoice.payment_failed event is sent, the subscription becomes past_due. + // Use this webhook to notify your user that their payment has + // failed and to retrieve new card details. + break; + + case "invoice.paid": + // Used to provision services after the trial has ended. + // The status of the invoice will show up as paid. Store the status in your + // database to reference when a user accesses your service to avoid hitting rate limits. + break; + + default: + // Unexpected event type + } + res.send(200); + } +} diff --git a/utils/init-middleware.js b/utils/init-middleware.js new file mode 100644 index 0000000..a70041d --- /dev/null +++ b/utils/init-middleware.js @@ -0,0 +1,13 @@ +// Helper method to wait for a middleware to execute before continuing +// And to throw an error when an error happens in a middleware +export default function initMiddleware(middleware) { + return (req, res) => + new Promise((resolve, reject) => { + middleware(req, res, (result) => { + if (result instanceof Error) { + return reject(result); + } + return resolve(result); + }); + }); +} diff --git a/utils/stripe.js b/utils/stripe.js new file mode 100644 index 0000000..008c467 --- /dev/null +++ b/utils/stripe.js @@ -0,0 +1,14 @@ +/** + * This is a singleton to ensure we only instantiate Stripe once. + */ +import { loadStripe } from "@stripe/stripe-js"; + +let stripePromise = null; +const getStripe = () => { + if (!stripePromise) { + stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY); + } + return stripePromise; +}; + +export default getStripe;