Add Stripe membership form

This commit is contained in:
Tony Grosinger 2023-12-25 15:34:39 -08:00
parent 9a32b8dfc0
commit 896bb5242a
7 changed files with 602 additions and 35 deletions

150
package-lock.json generated
View File

@ -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",

View File

@ -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"
},

View File

@ -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<NextResponse> {
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 },
);
}
}

View File

@ -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() {
</SocialLink>
</ul>
</div>
<div>
<ClubPayment />
</div>
</div>
</Container>
)

395
src/app/club/payment.tsx Normal file
View File

@ -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 (<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z">
<animateTransform attributeName="transform" dur="1s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12" />
</path>
</svg>);
}
function CheckoutForm({
paymentIntentID
}: {
paymentIntentID: string
}): React.JSX.Element {
const [selectedMembershipLevel, setSelectedMembershipLevel] = useState(membershipLevels[0]);
const [selectedAdditionalDonation, setSelectedAdditionalDonation] = useState<number | null>(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<string>('');
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 (
<>
<form id="payment-form" onSubmit={handleSubmit} className="m-auto space-y-4">
{/* Membership Type */}
<RadioGroup value={selectedMembershipLevel} onChange={setSelectedMembershipLevel} className="space-y-3">
<RadioGroup.Label className="text-base font-semibold leading-6 text-gray-900">
Select a membership type
</RadioGroup.Label>
<div className="grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
{membershipLevels.map((membership) => (
<RadioGroup.Option
key={membership.id}
value={membership}
className={({ active }) =>
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 }) => (
<>
<span className="flex flex-1 items-center justify-between ">
<span className="flex flex-col">
<RadioGroup.Label as="span" className="block text-sm font-medium text-gray-900">
{membership.title}
</RadioGroup.Label>
<RadioGroup.Description as="span" className="mt-1 flex items-center text-sm text-gray-500">
{membership.description}
</RadioGroup.Description>
</span>
<RadioGroup.Description as="span" className="ml-8 text-sm font-medium">
<span className="text-gray-900">${membership.price}</span>
<span className="text-gray-500">/yr</span>
</RadioGroup.Description>
</span>
<span
className={classNames(
active ? 'border' : 'border-2',
checked ? 'border-indigo-600' : 'border-transparent',
'pointer-events-none absolute -inset-px rounded-lg'
)}
aria-hidden="true"
/>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
{/* Additional donation */}
<RadioGroup value={selectedAdditionalDonation} onChange={setSelectedAdditionalDonation} className="space-y-3">
<RadioGroup.Label className="text-base font-semibold leading-6 text-gray-900">
Additional donation
</RadioGroup.Label>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-5">
{additionalDonationLevels.map((option) => (
<RadioGroup.Option
key={option}
value={option}
className={({ active, checked }) =>
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
? (
<RadioGroup.Label as="span" className="flex flex-col items-center">
<div className="relative rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-500 sm:text-sm">$</span>
</div>
<input
type="number"
name="custom-amount"
id="custom-amount"
value={customAmount}
min="0"
step="1"
onChange={(e) => 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"
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-500 sm:text-sm" id="price-currency">
/yr
</span>
</div>
</div>
</RadioGroup.Label>
)
: (
<RadioGroup.Label as="span">
<span>${option}</span>
<span className="text-gray-500">/yr</span>
</RadioGroup.Label>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
<div className="space-y-3">
<h2 className="text-base font-semibold leading-6 text-gray-900">
About you
</h2>
<div className="rounded-md mb-3 px-3 pb-1.5 pt-2.5 shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-indigo-600">
<label htmlFor="name" className="block text-xs font-medium text-gray-900">
Email
</label>
<input
type="email"
name="email"
id="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div className="rounded-md mb-3 px-3 pb-1.5 pt-2.5 shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-indigo-600">
<label htmlFor="name" className="block text-xs font-medium text-gray-900">
Phone
</label>
<input
type="tel"
name="phone"
id="phone"
value={phone}
onChange={(e) => 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"
/>
</div>
<fieldset>
<div className="mb-3 -space-y-px rounded-md bg-white shadow-sm">
<div className="flex -space-x-px">
<div className="w-2/3 min-w-0 flex-1">
<label htmlFor="street-address" className="sr-only">
Street Address
</label>
<input
type="text"
name="street-address"
id="street-address"
value={street}
onChange={(e) => 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"
/>
</div>
<div className="min-w-0 flex-1">
<label htmlFor="zipcode" className="sr-only">
Zipcode
</label>
<input
type="text"
name="zipcode"
id="zipcode"
value={zip}
onChange={(e) => 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"
/>
</div>
</div>
</div>
</fieldset>
</div>
<div className="space-y-3">
<h2 className="text-base font-semibold leading-6 text-gray-900">
Payment
</h2>
<div className="mt-1 text-sm text-gray-500">
If you would like to pay by cash or check, please instead
<a className="underline mx-1" href="/WSCC-Membership-Form.pdf">fill out a paper form</a>
and mail to the address on the form.
</div>
<PaymentElement id="payment-element" />
</div>
{/* TODO: Automatically renew toggle? */}
<button
className="mt-6 w-full rounded-md flex justify-center border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
disabled={isLoading || !stripe || !elements}
id="submit"
>
{isLoading ? (
<Spinner />
) : (
'Pay $' + totalAmount
)}
</button>
{/* Show any error or success messages */}
{message && <div id="payment-message">{message}</div>}
</form>
</>
);
}
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 && (
<Elements options={options}
stripe={stripe}>
<CheckoutForm paymentIntentID={paymentIntent} />
</Elements>
)}
</>
}

View File

@ -3,15 +3,19 @@ import { type Metadata } from 'next'
import { SimpleLayout } from '@/components/SimpleLayout'
export const metadata: Metadata = {
title: 'Youre subscribed',
description: 'Thanks for subscribing to my newsletter.',
title: 'Thank You',
description: 'Thanks for becoming a member.',
}
export default function ThankYou() {
return (
<SimpleLayout
title="Thanks for subscribing."
intro="Ill send you an email any time I publish a new blog post, release a new project, or have anything interesting to share that I think youd want to hear about. You can unsubscribe at any time, no hard feelings."
/>
title="Thanks for becoming a member."
intro="Thank you for joining the West Sound Community Club."
>
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
Your financial support helps us perserve this historic building and to host events for the community. We&apos;ll add you to our member mailing list so you receive announcement emails about upcoming events.
</p>
</SimpleLayout>
)
}

View File

@ -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' }],