diff --git a/.editorconfig b/.editorconfig index 937b121..2e009f1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,6 +23,12 @@ insert_final_newline = false #### .NET Coding Conventions #### [*.{cs,vb}] +# Analyzer severity levels +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0100.severity = none +dotnet_diagnostic.IDE3000.severity = none +dotnet_diagnostic.CA1848.severity = none + # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cb7cf60..2f7ec11 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,7 +32,7 @@ jobs: - name: Build and push server image run: | TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ steps.version.outputs.version }} - docker build -t $TAG src/ + docker build --build-arg VITE_STRIPE_API_KEY=${{ secrets.VITE_STRIPE_API_KEY }} -t $TAG src/ docker push $TAG deploy: name: Deploy to server @@ -52,13 +52,17 @@ jobs: script: | TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ needs.build.outputs.version }} - docker stop groundsforsupport.stevanfreeborn.com - docker rm groundsforsupport.stevanfreeborn.com + docker stop groundsforsupport.stevanfreeborn.com || true + docker rm groundsforsupport.stevanfreeborn.com || true docker pull $TAG docker run \ --restart always \ -d \ -p 4343:8080 \ + -e StripeOptions__ApiKey=${{ secrets.STRIPE_API_KEY }} \ + -e StripeOptions__EventsWebhookSecret=${{ secrets.STRIPE_EVENTS_WEBHOOK_SECRET }} \ + -e ContextOptions__DatabaseFilePath=/app/data/groundsforsupport.db \ + -v /home/${{ secrets.SSH_USERNAME }}/groundsforsupport.stevanfreeborn.com/data:/app/data \ --name groundsforsupport.stevanfreeborn.com \ $TAG diff --git a/.gitignore b/.gitignore index f588023..ab2381a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ appsettings*.json !appsettings.Example.json wwwroot/ +*.db +*.db-shm +*.db-wal # dotenv files .env diff --git a/GroundsForSupport.sln b/GroundsForSupport.sln index 37e03a0..539cc38 100644 --- a/GroundsForSupport.sln +++ b/GroundsForSupport.sln @@ -11,7 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GroundsForSupport.Server.Tests", "GroundsForSupport.Server.Tests", "{56565355-472A-D7A5-BBD6-5A9243A600D2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GroundsForSupport.API.Tests", "tests\GroundsForSupport.Server.Tests\GroundsForSupport.API.Tests.csproj", "{73D83B11-C5BC-4C4B-82BF-2DCEE689EDDC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GroundsForSupport.Server.Tests", "tests\GroundsForSupport.Server.Tests\GroundsForSupport.Server.Tests.csproj", "{73D83B11-C5BC-4C4B-82BF-2DCEE689EDDC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/README.md b/README.md index 8b4593a..3c5a166 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,105 @@ # Grounds for Support -⚠️ Under Construction ⚠️ +Grounds for Support is a full-stack web application designed to facilitate donations ("buying a coffee") using Stripe. It demonstrates a modern integration of a React frontend with an ASP.NET Core backend, handling secure payments, webhooks, and data persistence. -This is a simple application I built to help me better understand how to intergrate Stripe so that I can accept payments from people over the interwebz. +## Features +- **Secure Donations**: Integrated with Stripe Payment Intents for secure transaction processing. +- **Custom Amounts**: Users can specify their donation amount, name, and a personal message. +- **Real-time Feedback**: Immediate confirmation of payment status. +- **Recent Activity**: Displays a list of recent supporters. +- **Rate Limiting**: API endpoints are protected against abuse. +- **Responsive Design**: Works seamlessly on desktop and mobile. + +## Tech Stack + +### Frontend + +- **Framework**: React 19 with TypeScript +- **Build Tool**: Vite +- **Styling**: CSS +- **Payments**: Stripe Elements (`@stripe/react-stripe-js`) +- **Testing**: Vitest, React Testing Library + +### Backend + +- **Framework**: ASP.NET Core (Minimal APIs) +- **Language**: C# +- **Database**: SQLite with Entity Framework Core +- **Payments**: Stripe.net SDK + +### DevOps + +- **Containerization**: Docker +- **CI/CD**: GitHub Actions (Build & Deploy) +- **Hosting**: Linux VPS (Ubuntu) + +## Getting Started + +### Prerequisites + +- .NET SDK +- Node.js +- Stripe Account (for API keys) + +### Local Development + +1. **Clone the repository** + + ```bash + git clone https://github.com/StevanFreeborn/groundsforsupport.stevanfreeborn.com.git + cd groundsforsupport.stevanfreeborn.com + ``` + +2. **Configure Environment** + - Update `src/GroundsForSupport.Server/appsettings.Development.json` (or use User Secrets) with your Stripe keys: + + ```json + "StripeOptions": { + "ApiKey": "sk_test_...", + "EventsWebhookSecret": "whsec_..." + } + ``` + + - Create a `.env` file in `src/GroundsForSupport.Client` with your publishable key: + + ```txt + VITE_STRIPE_API_KEY=pk_test_... + ``` + +3. **Run the Application** + The server project is configured to build the client automatically. + + ```bash + cd src/GroundsForSupport.Server + dotnet run + ``` + + The API will be available at `https://localhost:7071` (or similar), and it will serve the static frontend files. + + *Alternatively, run frontend separately for hot-reloading:* + + ```bash + cd src/GroundsForSupport.Client + npm install + npm run dev + ``` + +## Testing + +- **Frontend Tests**: + + ```bash + cd src/GroundsForSupport.Client + npm test + ``` + +- **Backend Tests**: + + ```bash + dotnet test + ``` + +## 📄 License + +This project is licensed under the MIT License. diff --git a/src/Dockerfile b/src/Dockerfile index 8fbac17..8c42e2c 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update && \ COPY ["GroundsForSupport.Server/GroundsForSupport.Server.csproj", "GroundsForSupport.Server/"] RUN dotnet restore "GroundsForSupport.Server/GroundsForSupport.Server.csproj" +ARG VITE_STRIPE_API_KEY +ENV VITE_STRIPE_API_KEY=$VITE_STRIPE_API_KEY COPY . . WORKDIR "/src/GroundsForSupport.Server" RUN dotnet build "GroundsForSupport.Server.csproj" -c Release -o /app/build diff --git a/src/GroundsForSupport.Client/example.env b/src/GroundsForSupport.Client/example.env new file mode 100644 index 0000000..83529ae --- /dev/null +++ b/src/GroundsForSupport.Client/example.env @@ -0,0 +1,2 @@ +NODE_ENV=MODE_PLACEHOLDER +VITE_STRIPE_API_KEY=VITE_STRIPE_API_KEY_PLACEHOLDER \ No newline at end of file diff --git a/src/GroundsForSupport.Client/package-lock.json b/src/GroundsForSupport.Client/package-lock.json index be14638..8dfdda5 100644 --- a/src/GroundsForSupport.Client/package-lock.json +++ b/src/GroundsForSupport.Client/package-lock.json @@ -8,6 +8,8 @@ "name": "groundsforsupport-client", "version": "0.0.0", "dependencies": { + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1123,6 +1125,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz", + "integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.0.tgz", + "integrity": "sha512-EB0/GGgs4hfezzkiMkinlRgWtjz8fSdwVQhwYS7Sg/RQrSvuNOz+ssPjD+lAzqaYTCB0zlbrt0fcqVziLJrufQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2851,7 +2876,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3262,6 +3286,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3403,6 +3439,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3620,6 +3665,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/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==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/src/GroundsForSupport.Client/package.json b/src/GroundsForSupport.Client/package.json index 04f08ca..d9c8a5e 100644 --- a/src/GroundsForSupport.Client/package.json +++ b/src/GroundsForSupport.Client/package.json @@ -12,6 +12,8 @@ "test": "vitest --coverage" }, "dependencies": { + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/src/GroundsForSupport.Client/public/espresso.png b/src/GroundsForSupport.Client/public/espresso.png new file mode 100644 index 0000000..a8ec147 Binary files /dev/null and b/src/GroundsForSupport.Client/public/espresso.png differ diff --git a/src/GroundsForSupport.Client/src/App.css b/src/GroundsForSupport.Client/src/App.css index c4d5437..e4b0f96 100644 --- a/src/GroundsForSupport.Client/src/App.css +++ b/src/GroundsForSupport.Client/src/App.css @@ -1,3 +1,19 @@ +main { + display: flex; + justify-content: center; + flex-wrap: wrap; + padding: 1rem; + padding-top: 2rem; + gap: 1rem; +} + +.payment, +.previous-donations { + --max-section-width: 31.25rem; + flex: 1 1 var(--max-section-width); + max-width: var(--max-section-width); +} + header { background-color: #181818; flex-direction: column; @@ -25,15 +41,17 @@ header .info { text-align: center; } +header .info p { + text-align: left; +} + form { + display: flex; + flex-direction: column; background-color: #181818; border-radius: 0.25rem; - flex-direction: column; gap: 0.5rem; - width: 100%; - max-width: 31.25rem; padding: 1rem; - display: flex; box-shadow: 0 4px 8px #0000001a; } @@ -47,6 +65,17 @@ form label { font-weight: 700; } +form label .detail { + font-size: 0.875rem; + color: #ccc; +} + +form label:has(+ input:required)::after { + content: ' [required]'; + font-size: 0.875rem; + color: #ccc; +} + form input { color: #e4e4e4; background-color: #282828; @@ -69,6 +98,15 @@ form input[type='number'] { appearance: textfield; } +form textarea { + color: #e4e4e4; + background-color: #282828; + border: 1px solid #444; + border-radius: 0.25rem; + padding: 0.5rem; + resize: vertical; +} + .error-message { color: #ff6b6b; font-size: 0.875rem; @@ -83,3 +121,152 @@ form button { font-weight: 700; transition: background-color 0.3s; } + +.payment-confirmation-loading { + text-align: center; + font-weight: 700; + padding: 2rem 0; +} + +.payment-confirmation-loading .spinner { + border: 4px solid #444; + border-top: 4px solid #b39cd0; + border-radius: 50%; + width: 3rem; + height: 3rem; + margin: 0 auto 1rem auto; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.payment-confirmation-card { + background-color: #181818; + border-radius: 0.25rem; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 31.25rem; + box-shadow: 0 4px 8px #0000001a; +} + +.previous-donations { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.previous-donations ul { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.previous-donations ul li { + display: flex; +} + +.previous-donations .load-more-button { + color: #fff; + background-color: #b39cd0; + border: none; + border-radius: 0.25rem; + padding: 0.75rem; + font-weight: 700; + transition: background-color 0.3s; +} + +.payment-card { + flex: 1; + background-color: #282828; + border-radius: 0.25rem; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.payment-card .header { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.payment-card .left, +.payment-card .right { + display: flex; +} + +.payment-card .left { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; +} + +.payment-card .left img { + width: 3rem; + height: 3rem; + border-radius: 0.25rem; +} + +.payment-card .header .right { + flex: 1; + gap: 1rem; +} + +.payment-card .header .info, +.payment-card .header .details { + flex: 1; +} + +.payment-card .header .right .info .date { + font-size: 0.875rem; + color: #aaa; +} + +.payment-card .header .right .details { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.payment-card .header .right .details .amount { + font-weight: 700; + font-size: 1.25rem; +} + +.payment-card .header .right .info, +.payment-card .header .right .details .amount, +.payment-card .message { + display: block; + min-width: 0; + overflow-wrap: break-word; + word-break: break-word; +} + +.payment-card .message { + font-style: italic; + color: #ccc; +} + +.return-home-link { + color: #b39cd0; + text-align: center; + margin-top: 1rem; + font-weight: 700; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/GroundsForSupport.Client/src/App.tsx b/src/GroundsForSupport.Client/src/App.tsx index 01e704f..ab3e419 100644 --- a/src/GroundsForSupport.Client/src/App.tsx +++ b/src/GroundsForSupport.Client/src/App.tsx @@ -1,64 +1,57 @@ import '@/App.css'; -import { useRef, useState } from 'react'; +import { Elements } from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { useState } from 'react'; +import CheckoutForm from './CheckoutForm'; +import PaymentForm from './PaymentForm'; +import PaymentConfirmationCard from './PaymentConfirmationCard'; +import PreviousPaymentsList from './PreviousPaymentsList'; + +const stripe = loadStripe(import.meta.env.VITE_STRIPE_API_KEY as string); function App() { - const [amount, setAmount] = useState(''); - const [email, setEmail] = useState(''); - const [errors, setErrors] = useState<{ amount?: string; email?: string }>({}); + const queryParams = new URLSearchParams(window.location.search); + const clientSecretFromUrl = queryParams.get('payment_intent_client_secret'); + const [secret, setSecret] = useState(clientSecretFromUrl ?? undefined); + const [isSubmitting, setIsSubmitting] = useState(false); - const amountInputRef = useRef(null); - const emailInputRef = useRef(null); + async function handleDonationFormSubmit(formData: { + name: string; + amount: number; + message?: string; + email?: string; + }) { + setIsSubmitting(true); - function handleAmountInput(event: React.ChangeEvent) { - setErrors((prevErrors) => ({ ...prevErrors, amount: undefined })); - const value = parseInt(event.target.value); - setAmount(isNaN(value) ? '' : value); - } + try { + const url = new URL('/payments/create-intent', import.meta.url); + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: formData.name, + amount: formData.amount, + message: formData.message, + email: formData.email, + }), + }); - function handleEmailInput(event: React.ChangeEvent) { - setErrors((prevErrors) => ({ ...prevErrors, email: undefined })); - setEmail(event.target.value); - } - - function validateForm() { - const newErrors: { amount?: string; email?: string } = {}; - - if (amount === '' || amount <= 0) { - newErrors.amount = 'Please enter a valid amount greater than 0.'; - } - - if (email.trim() !== '' && emailInputRef.current?.validity.typeMismatch) { - newErrors.email = 'Please enter a valid email address.'; - } - - setErrors(newErrors); - - const isValid = Object.keys(newErrors).length === 0; - - if (isValid === false) { - if (newErrors.amount) { - amountInputRef.current?.focus(); - } else if (newErrors.email) { - emailInputRef.current?.focus(); + if (!res.ok) { + console.error('Failed to create payment intent'); + alert('An error occurred while creating the payment. Please try again.'); + return; } + + const data = await res.json() as { clientSecret: string }; + setSecret(data.clientSecret); + } catch (err) { + console.error(err); + alert('An error occurred while creating the payment. Please try again.'); + } finally { + setIsSubmitting(false); } - - return isValid; - } - - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - - const isValid = validateForm(); - - if (isValid === false) { - return; - } - - // TODO: We will stop displaying our form - // and we will initialize the stripe embedded - // elements and pass along the amount and email - alert(`Form is valid! Amount: ${amount}, Email: ${email}`); } return ( @@ -72,49 +65,58 @@ function App() {

Stevan Freeborn

I'm a dad of 2 who enjoys drinking coffee, lifting weights, and solving problems with - code. You really don't need to buy me a coffee. + code. If you have found my open helpful, consider supporting me with a donation which I + will more than likely spend on more coffee!

-
-
- - + {secret === undefined ? ( + - {errors.amount} -
-
- - - - {errors.email} - -
- -
+ {clientSecretFromUrl ? ( + + ) : ( + + )} + + )} + + {clientSecretFromUrl === null ? : null}
); diff --git a/src/GroundsForSupport.Client/src/CheckoutForm.tsx b/src/GroundsForSupport.Client/src/CheckoutForm.tsx new file mode 100644 index 0000000..245a8f4 --- /dev/null +++ b/src/GroundsForSupport.Client/src/CheckoutForm.tsx @@ -0,0 +1,48 @@ +import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { useState } from 'react'; + +export default function CheckoutForm() { + const [isSubmitting, setIsSubmitting] = useState(false); + const stripe = useStripe(); + const elements = useElements(); + const isLoading = !stripe || !elements; + + async function handleSubmit(e: React.FormEvent) { + setIsSubmitting(true); + + try { + e.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: window.location.origin, + }, + }); + + console.error(error); + alert(error?.message || 'An unexpected error occurred.'); + } finally { + setIsSubmitting(false); + } + } + + return ( +
void handleSubmit(e)}> + + + + ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentCard.tsx b/src/GroundsForSupport.Client/src/PaymentCard.tsx new file mode 100644 index 0000000..5179570 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentCard.tsx @@ -0,0 +1,44 @@ +export type Payment = { + name: string; + amount: number; + message?: string; + createdAtUnix: number; +}; + +export default function PaymentCard({ payment }: { payment: Payment }) { + const formattedPayment = payment.amount / 100; + + const date = new Date(payment.createdAtUnix); + const userLocale = navigator.language || 'en-US'; + const formattedDate = date.toLocaleDateString(userLocale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return ( +
+
+
+ Avatar +
+
+
+
{payment.name}
+
{formattedDate}
+
+
+
${formattedPayment.toFixed(2)}
+
+
+
+ {payment.message &&
{payment.message}
} +
+ ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx b/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx new file mode 100644 index 0000000..96f2547 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx @@ -0,0 +1,100 @@ +import { useStripe } from '@stripe/react-stripe-js'; +import type { PaymentIntent } from '@stripe/stripe-js'; +import { useEffect, useState } from 'react'; + +type PaymentConfirmationCardProps = { + clientSecret: string; +}; + +type PaymentIntentData = + | { + status: 'loading'; + } + | { + status: 'loaded'; + intent: PaymentIntent; + } + | { + status: 'error'; + error: string; + }; + +export default function PaymentConfirmationCard({ clientSecret }: PaymentConfirmationCardProps) { + const stripe = useStripe(); + + const [data, setData] = useState({ status: 'loading' }); + + useEffect(() => { + let isMounted = true; + + async function fetchPaymentIntent() { + if (!stripe) { + return; + } + + try { + const { paymentIntent, error } = await stripe.retrievePaymentIntent(clientSecret); + + if (!isMounted) return; + + if (error) { + setData({ status: 'error', error: error.message ?? 'Unknown error' }); + return; + } + + if (paymentIntent) { + setData({ status: 'loaded', intent: paymentIntent }); + return; + } + + setData({ status: 'error', error: 'PaymentIntent not found' }); + } catch (err) { + if (!isMounted) { + return; + } + + console.error(err); + setData({ status: 'error', error: (err as Error).message }); + } + } + + void fetchPaymentIntent(); + + return () => { + isMounted = false; + }; + }, [stripe, clientSecret]); + + if (data.status === 'loading') { + return ( +
+
+ Checking payment status... +
+ ); + } + + return ( +
+ {data.status === 'error' ? ( + <> +

Payment Error

+

There was an error processing your payment: {data.error}

+ + ) : ( + <> +

Thank you!

+

Your payment was successful. I appreciate your support!

+ + )} + +
+ ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentForm.tsx b/src/GroundsForSupport.Client/src/PaymentForm.tsx new file mode 100644 index 0000000..cc88d80 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentForm.tsx @@ -0,0 +1,200 @@ +import { useState, useRef } from 'react'; + +type DonationFormProps = { + onValidSubmit: (data: { name: string; amount: number; message?: string; email?: string }) => Promise | void; + isSubmitting?: boolean; +}; + +export default function PaymentForm({ onValidSubmit, isSubmitting }: DonationFormProps) { + const [name, setName] = useState(''); + const [amount, setAmount] = useState(''); + const [message, setMessage] = useState(''); + const [email, setEmail] = useState(''); + const [errors, setErrors] = useState<{ + name?: string; + amount?: string; + email?: string; + message?: string; + }>({}); + + const nameInputRef = useRef(null); + const amountInputRef = useRef(null); + const messageTextAreaRef = useRef(null); + const emailInputRef = useRef(null); + + function handleNameInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, name: undefined })); + setName(event.target.value); + } + + function handleAmountInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, amount: undefined })); + const value = parseInt(event.target.value); + setAmount(isNaN(value) ? '' : value); + } + + function handleMessageInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, message: undefined })); + setMessage(event.target.value); + } + + function handleEmailInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, email: undefined })); + setEmail(event.target.value); + } + + function validateForm() { + const newErrors: { amount?: string; email?: string; name?: string; message?: string } = {}; + + if (name.trim() === '') { + newErrors.name = 'Please enter your name.'; + } + + if (nameInputRef.current?.validity.tooLong) { + newErrors.name = 'Name must be 60 characters or less.'; + } + + if (amount === '' || amountInputRef.current?.validity.rangeUnderflow || amount <= 0) { + newErrors.amount = 'Please enter a valid amount greater than 0.'; + } + + if (message.length > 250) { + newErrors.message = 'Message must be 250 characters or less.'; + } + + if (email.trim() !== '' && emailInputRef.current?.validity.typeMismatch) { + newErrors.email = 'Please enter a valid email address.'; + } + + setErrors(newErrors); + + const isValid = Object.keys(newErrors).length === 0; + + if (isValid === false) { + if (newErrors.name) { + nameInputRef.current?.focus(); + } else if (newErrors.amount) { + amountInputRef.current?.focus(); + } else if (newErrors.message) { + messageTextAreaRef.current?.focus(); + } else if (newErrors.email) { + emailInputRef.current?.focus(); + } + } + + return isValid; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const isValid = validateForm(); + + if (isValid === false) { + return; + } + + if (amount === '') { + throw new Error('amount after validation should never be an empty string'); + } + + await onValidSubmit({ name, amount, message, email }); + } + + return ( +
void handleSubmit(e)} + noValidate + > +
+ + + + {errors.name} + +
+
+ + + + {errors.amount} + +
+
+ +