From 896bb5242ab4f89c436c467ebdb25a8db712e724 Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Mon, 25 Dec 2023 15:34:39 -0800 Subject: [PATCH] Add Stripe membership form --- package-lock.json | 150 +++++++++-- package.json | 6 +- src/app/api/stripe_intent/route.ts | 60 +++++ src/app/club/page.tsx | 6 + src/app/club/payment.tsx | 395 +++++++++++++++++++++++++++++ src/app/thank-you/page.tsx | 14 +- tailwind.config.ts | 6 +- 7 files changed, 602 insertions(+), 35 deletions(-) create mode 100644 src/app/api/stripe_intent/route.ts create mode 100644 src/app/club/payment.tsx diff --git a/package-lock.json b/package-lock.json index addf246..8059866 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,15 @@ "name": "wscc-website", "version": "0.1.0", "dependencies": { - "@headlessui/react": "^1.7.13", + "@headlessui/react": "1.7.17", "@heroicons/react": "^2.0.18", "@mapbox/rehype-prism": "^0.8.0", "@mdx-js/loader": "^2.3.0", "@mdx-js/react": "^2.3.0", "@next/mdx": "13.4.16", + "@stripe/react-stripe-js": "2.4.0", + "@stripe/stripe-js": "2.2.1", + "@tailwindcss/forms": "0.5.7", "@tailwindcss/typography": "^0.5.4", "@types/node": "20.4.7", "@types/react": "18.2.18", @@ -29,6 +32,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "remark-gfm": "^3.0.1", + "stripe": "14.9.0", "tailwindcss": "^3.3.3", "typescript": "5.1.6" }, @@ -129,9 +133,9 @@ } }, "node_modules/@headlessui/react": { - "version": "1.7.15", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.15.tgz", - "integrity": "sha512-OTO0XtoRQ6JPB1cKNFYBZv2Q0JMqMGNhYP1CjPvcJvjz8YGokz8oAj89HIYZGN0gZzn/4kk9iUpmMF4Q21Gsqw==", + "version": "1.7.17", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.17.tgz", + "integrity": "sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==", "dependencies": { "client-only": "^0.0.1" }, @@ -565,6 +569,24 @@ "node": ">=6" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.4.0.tgz", + "integrity": "sha512-1jVQEL3OuhuzNlf4OdfqovHt+MkWh8Uh8xpLxx/xUFUDdF+7/kDOrGKy+xJO3WLCfZUL7NAy+/ypwXbbYZi0tg==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.2.1.tgz", + "integrity": "sha512-Wd81A0u8EwT3cf+Xv1mpMI18RbXVhgh19MtPcF9ojNTlG3kl36B1+XFe1KQfnJxD3WRnVfDuI0rNCK53mcGm6g==" + }, "node_modules/@swc/helpers": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz", @@ -584,6 +606,17 @@ "node": ">=6" } }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz", + "integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + } + }, "node_modules/@tailwindcss/typography": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.9.tgz", @@ -1683,7 +1716,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -3524,7 +3556,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -3836,7 +3867,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3848,7 +3878,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -6714,6 +6743,14 @@ "node": ">=4" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7129,7 +7166,6 @@ "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7863,7 +7899,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -7912,6 +7947,20 @@ "node": ">=8" } }, + "node_modules/qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7994,8 +8043,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/read-cache": { "version": "1.0.0", @@ -8635,7 +8683,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -8994,6 +9041,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "14.9.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.9.0.tgz", + "integrity": "sha512-t2XdpNbRH4x3MYEoxNWhwUPl9D80aUd5OHds0zhDiwRYPZ0H7MrUI/dj9wOSYlzycD3xdvjn0q7pWeFWljtMUQ==", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/style-to-object": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz", @@ -10419,9 +10478,9 @@ "dev": true }, "@headlessui/react": { - "version": "1.7.15", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.15.tgz", - "integrity": "sha512-OTO0XtoRQ6JPB1cKNFYBZv2Q0JMqMGNhYP1CjPvcJvjz8YGokz8oAj89HIYZGN0gZzn/4kk9iUpmMF4Q21Gsqw==", + "version": "1.7.17", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.17.tgz", + "integrity": "sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==", "requires": { "client-only": "^0.0.1" } @@ -10695,6 +10754,19 @@ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==" }, + "@stripe/react-stripe-js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.4.0.tgz", + "integrity": "sha512-1jVQEL3OuhuzNlf4OdfqovHt+MkWh8Uh8xpLxx/xUFUDdF+7/kDOrGKy+xJO3WLCfZUL7NAy+/ypwXbbYZi0tg==", + "requires": { + "prop-types": "^15.7.2" + } + }, + "@stripe/stripe-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.2.1.tgz", + "integrity": "sha512-Wd81A0u8EwT3cf+Xv1mpMI18RbXVhgh19MtPcF9ojNTlG3kl36B1+XFe1KQfnJxD3WRnVfDuI0rNCK53mcGm6g==" + }, "@swc/helpers": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz", @@ -10711,6 +10783,14 @@ "defer-to-connect": "^1.0.1" } }, + "@tailwindcss/forms": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz", + "integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==", + "requires": { + "mini-svg-data-uri": "^1.2.3" + } + }, "@tailwindcss/typography": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.9.tgz", @@ -11550,7 +11630,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "requires": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -12895,7 +12974,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dev": true, "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -13125,14 +13203,12 @@ "has-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==" }, "has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, "has-tostringtag": { "version": "1.0.0", @@ -15094,6 +15170,11 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" }, + "mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==" + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -15390,8 +15471,7 @@ "object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "dev": true + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" }, "object-keys": { "version": "1.1.1", @@ -15843,7 +15923,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -15882,6 +15961,14 @@ "escape-goat": "^2.0.0" } }, + "qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "requires": { + "side-channel": "^1.0.4" + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -15940,8 +16027,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "read-cache": { "version": "1.0.0", @@ -16394,7 +16480,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "requires": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -16643,6 +16728,15 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "stripe": { + "version": "14.9.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-14.9.0.tgz", + "integrity": "sha512-t2XdpNbRH4x3MYEoxNWhwUPl9D80aUd5OHds0zhDiwRYPZ0H7MrUI/dj9wOSYlzycD3xdvjn0q7pWeFWljtMUQ==", + "requires": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + } + }, "style-to-object": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz", diff --git a/package.json b/package.json index ffe8816..82352c3 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,15 @@ }, "browserslist": "defaults, not ie <= 11", "dependencies": { - "@headlessui/react": "^1.7.13", + "@headlessui/react": "1.7.17", "@heroicons/react": "^2.0.18", "@mapbox/rehype-prism": "^0.8.0", "@mdx-js/loader": "^2.3.0", "@mdx-js/react": "^2.3.0", "@next/mdx": "13.4.16", + "@stripe/react-stripe-js": "2.4.0", + "@stripe/stripe-js": "2.2.1", + "@tailwindcss/forms": "0.5.7", "@tailwindcss/typography": "^0.5.4", "@types/node": "20.4.7", "@types/react": "18.2.18", @@ -31,6 +34,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "remark-gfm": "^3.0.1", + "stripe": "14.9.0", "tailwindcss": "^3.3.3", "typescript": "5.1.6" }, diff --git a/src/app/api/stripe_intent/route.ts b/src/app/api/stripe_intent/route.ts new file mode 100644 index 0000000..cdb8c66 --- /dev/null +++ b/src/app/api/stripe_intent/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || ''); + +export async function POST(request: NextRequest): Promise { + const { amount, payment_intent_id } = await request.json(); + + if (payment_intent_id) { + try { + // If a payment_intent_id is passed, retrieve the paymentIntent + const current_intent = await stripe.paymentIntents.retrieve( + payment_intent_id, + ); + // If a paymentIntent is retrieved update its amount + if (current_intent) { + const updated_intent = await stripe.paymentIntents.update( + payment_intent_id, + { + amount: amount, + }, + ); + + return NextResponse.json(updated_intent, { status: 200 }); + } + } catch (e) { + // Catch any error and return a status 500 + if ((e as any).code !== 'resource_missing') { + const errorMessage = + e instanceof Error ? e.message : 'Internal server error'; + return NextResponse.json( + { statusCode: 500, message: errorMessage }, + { status: 500 }, + ); + } + } + } + + try { + // Create PaymentIntent + const params = { + amount: amount, + currency: 'usd', + payment_method_types: ['card'], + automatic_payment_methods: { + enabled: false, + }, + }; + const payment_intent = await stripe.paymentIntents.create(params); + //Return the payment_intent object + return NextResponse.json(payment_intent, { status: 200 }); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Internal server error'; + return NextResponse.json( + { statusCode: 500, message: errorMessage }, + { status: 500 }, + ); + } +} diff --git a/src/app/club/page.tsx b/src/app/club/page.tsx index 8f05174..3242b54 100644 --- a/src/app/club/page.tsx +++ b/src/app/club/page.tsx @@ -1,3 +1,4 @@ + import { type Metadata } from 'next' import Image from 'next/image' import Link from 'next/link' @@ -6,6 +7,7 @@ import clsx from 'clsx' import { Container } from '@/components/Container' import { UserPlusIcon, GiftIcon, EnvelopeIcon } from '@heroicons/react/24/solid' import interiorEmptyImage from '@/images/photos/interior-empty.jpg' +import { ClubPayment } from './payment'; function SocialLink({ className, @@ -37,6 +39,7 @@ export const metadata: Metadata = { 'The West Sound Community Club on Orcas Island.', } + // TODO: Replace interiorEmptyImage with a photo from a potluck export default function Club() { @@ -97,6 +100,9 @@ export default function Club() { +
+ +
) diff --git a/src/app/club/payment.tsx b/src/app/club/payment.tsx new file mode 100644 index 0000000..7d0a23f --- /dev/null +++ b/src/app/club/payment.tsx @@ -0,0 +1,395 @@ +"use client" + +import React, { useEffect, useState, FormEvent } from 'react'; +import { Elements } from '@stripe/react-stripe-js'; +import { Appearance, StripeElementsOptions, loadStripe } from '@stripe/stripe-js'; +import { + PaymentElement, + useStripe, + useElements, +} from '@stripe/react-stripe-js'; +import { RadioGroup } from '@headlessui/react' + +const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''); + +const membershipLevels = [ + { id: 1, title: 'Individual', description: 'One vote', price: 20 }, + { id: 2, title: 'Household', description: 'Two votes', price: 30 }, +]; + +const additionalDonationLevels = [10, 50, 100, -1]; + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(' '); +} + +function Spinner(): React.JSX.Element { + return ( + + + + ); +} + +function CheckoutForm({ + paymentIntentID +}: { + paymentIntentID: string +}): React.JSX.Element { + const [selectedMembershipLevel, setSelectedMembershipLevel] = useState(membershipLevels[0]); + const [selectedAdditionalDonation, setSelectedAdditionalDonation] = useState(null); + const [customAmount, setCustomAmount] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + const [street, setStreet] = useState(''); + const [zip, setZip] = useState(''); + const [totalAmount, setTotalAmount] = useState(300); + const [message, setMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const stripe = useStripe(); + const elements = useElements(); + + useEffect(() => { + if (!stripe) { + return; + } + + //Grab the client secret from url params + const clientSecret = new URLSearchParams(window.location.search).get( + 'payment_intent_client_secret' + ); + + if (!clientSecret) { + return; + } + + stripe.retrievePaymentIntent(clientSecret).then(({ paymentIntent }) => { + switch (paymentIntent?.status) { + case 'succeeded': + setMessage('Payment succeeded!'); + break; + case 'processing': + setMessage('Your payment is processing.'); + break; + case 'requires_payment_method': + setMessage('Your payment was not successful, please try again.'); + break; + default: + setMessage('Something went wrong.'); + break; + } + }); + }, [stripe]); + + useEffect(() => { + let subtotal = selectedMembershipLevel.price + + if (selectedAdditionalDonation && selectedAdditionalDonation !== -1) { + subtotal += selectedAdditionalDonation + } else if (customAmount !== '') { + try { + subtotal += parseFloat(customAmount) + } catch { + console.error('') + } + } + + setTotalAmount(subtotal); + + fetch('api/stripe_intent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: subtotal * 100, + payment_intent_id: paymentIntentID, + }), + }); + }, [paymentIntentID, selectedMembershipLevel, selectedAdditionalDonation, customAmount]) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js has not yet loaded. + return; + } + + setIsLoading(true); + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: window.location.origin + '/thank-you', + receipt_email: email, + payment_method_data: { + billing_details: { + name: 'Billing user', + }, + }, + }, + }); + + if (error.type === 'card_error' || error.type === 'validation_error') { + setMessage(error.message || ''); + } else { + setMessage('An unexpected error occured.'); + } + + setIsLoading(false); + }; + + return ( + <> +
+ + {/* Membership Type */} + + + Select a membership type + + +
+ {membershipLevels.map((membership) => ( + + classNames( + active ? 'border-indigo-600 ring-2 ring-indigo-600' : 'border-gray-300', + 'relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none' + ) + } + > + {({ checked, active }) => ( + <> + + + + {membership.title} + + + {membership.description} + + + + ${membership.price} + /yr + + + + ))} +
+
+ + {/* Additional donation */} + + + Additional donation + +
+ {additionalDonationLevels.map((option) => ( + + classNames( + 'cursor-pointer focus:outline-none', + option === -1 ? 'col-span-2' : '', + checked + ? 'ring-2 ring-indigo-600 ring-offset-2' + : 'ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-gray-50', + 'flex items-center justify-center rounded-md py-3 px-3 text-sm font-semibold sm:flex-1' + ) + } + > + + {option === -1 + ? ( + +
+
+ $ +
+ setCustomAmount(e.target.value)} + className="block w-full rounded-md border-0 py-1.5 pl-7 pr-12 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-1 focus:ring-inset focus:ring-gray-500 sm:text-sm sm:leading-6" + placeholder="Custom" + aria-describedby="price-currency" + /> +
+ + /yr + +
+
+
+ ) + : ( + + ${option} + /yr + + )} +
+ ))} +
+
+ +
+

+ About you +

+ +
+ + setEmail(e.target.value)} + className="block w-full border-0 p-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" + placeholder="you@example.com" + /> +
+
+ + setPhone(e.target.value)} + className="block w-full border-0 p-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" + placeholder="360-376-XXXX" + /> +
+
+
+
+
+ + setStreet(e.target.value)} + className="relative block w-full rounded-none rounded-l-md border-0 bg-transparent py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + placeholder="Street" + /> +
+
+ + setZip(e.target.value)} + className="relative block w-full rounded-none rounded-r-md border-0 bg-transparent py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + placeholder="98XXX" + /> +
+
+
+
+
+ +
+

+ Payment +

+
+ If you would like to pay by cash or check, please instead + fill out a paper form + and mail to the address on the form. +
+ +
+ + {/* TODO: Automatically renew toggle? */} + + + + {/* Show any error or success messages */} + {message &&
{message}
} +
+ + ); +} + +export function ClubPayment() { + const [clientSecret, setClientSecret] = useState(''); + const [paymentIntent, setPaymentIntent] = useState(''); + useEffect(() => { + // Create PaymentIntent as soon as the page loads using our local API + fetch('api/stripe_intent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + amount: 30000, + payment_intent_id: '', + }), + }) + .then((res) => res.json()) + .then((data) => { + setClientSecret(data.client_secret); + setPaymentIntent(data.id); + }); + }, []); + + const appearance: Appearance = { + theme: 'stripe', + labels: 'floating', + }; + + const options: StripeElementsOptions = { + clientSecret, + appearance + } + return <> + {clientSecret && ( + + + + )} + +} + + diff --git a/src/app/thank-you/page.tsx b/src/app/thank-you/page.tsx index 359271e..e0d8581 100644 --- a/src/app/thank-you/page.tsx +++ b/src/app/thank-you/page.tsx @@ -3,15 +3,19 @@ import { type Metadata } from 'next' import { SimpleLayout } from '@/components/SimpleLayout' export const metadata: Metadata = { - title: 'You’re subscribed', - description: 'Thanks for subscribing to my newsletter.', + title: 'Thank You', + description: 'Thanks for becoming a member.', } export default function ThankYou() { return ( + title="Thanks for becoming a member." + intro="Thank you for joining the West Sound Community Club." + > +

+ Your financial support helps us perserve this historic building and to host events for the community. We'll add you to our member mailing list so you receive announcement emails about upcoming events. +

+
) } diff --git a/tailwind.config.ts b/tailwind.config.ts index 157a3c7..1e732e5 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,5 @@ import typographyPlugin from '@tailwindcss/typography' +import formsPlugin from '@tailwindcss/forms'; import { type Config } from 'tailwindcss' import typographyStyles from './typography' @@ -6,7 +7,10 @@ import typographyStyles from './typography' export default { content: ['./src/**/*.{js,jsx,ts,tsx}'], darkMode: 'class', - plugins: [typographyPlugin], + plugins: [ + typographyPlugin, + formsPlugin, + ], theme: { fontSize: { xs: ['0.8125rem', { lineHeight: '1.5rem' }],