From 52c6c8288600efae7cdbc24182d2afc82461adcd Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:13:08 -0600 Subject: [PATCH] feat(client): add payment components --- src/GroundsForSupport.Client/src/App.css | 195 +++++++++++++++++- src/GroundsForSupport.Client/src/App.tsx | 182 ++++++++-------- src/GroundsForSupport.Client/src/index.css | 9 - .../tests/App.spec.tsx | 33 ++- .../Integration/PaymentTests.cs | 7 +- 5 files changed, 316 insertions(+), 110 deletions(-) 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..ac4a4b0 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); 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(); + 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/index.css b/src/GroundsForSupport.Client/src/index.css index a06c4bb..3d5f015 100644 --- a/src/GroundsForSupport.Client/src/index.css +++ b/src/GroundsForSupport.Client/src/index.css @@ -69,12 +69,3 @@ body { background-color: var(--bg-color); color: var(--text-color); } - -main { - display: flex; - flex-direction: column; - align-items: center; - padding: 1rem; - padding-top: 2rem; -} - diff --git a/src/GroundsForSupport.Client/tests/App.spec.tsx b/src/GroundsForSupport.Client/tests/App.spec.tsx index 7eed8bd..f6d8dac 100644 --- a/src/GroundsForSupport.Client/tests/App.spec.tsx +++ b/src/GroundsForSupport.Client/tests/App.spec.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import App from '@/App.tsx'; import { render } from '@testing-library/react'; import '@testing-library/jest-dom'; @@ -25,8 +25,36 @@ describe('profile info', () => { describe('form', () => { let user: UserEvent; + const mockFetch = vi.fn(); + beforeEach(() => { user = userEvent.setup(); + vi.stubGlobal('fetch', mockFetch); + + mockFetch.mockImplementation(async (url: URL) => { + if (url.toString().endsWith('/payments/create-intent')) { + return { + ok: true, + json: async () => ({ + clientSecret: 'test_client_secret', + }), + }; + } + + if (url.toString().endsWith('/payments')) { + return { + ok: true, + json: async () => ({ + payments: [], + }), + }; + } + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + mockFetch.mockReset(); }); test('it should have an amount input', () => { @@ -83,8 +111,6 @@ describe('form', () => { }); test('it should allow form to be submitted without email', async () => { - const windowSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); - const screen = render(); const amountInput = screen.getByLabelText('Amount'); @@ -93,7 +119,6 @@ describe('form', () => { await user.type(amountInput, '10'); await user.click(submitButton); - expect(windowSpy).toHaveBeenCalledWith('Form is valid! Amount: 10, Email: '); }); test('it should validate email if provided', async () => { diff --git a/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs index 86b1d85..a8dc1be 100644 --- a/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs +++ b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs @@ -20,7 +20,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture email = string.Empty, }; - var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } @@ -36,7 +36,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture email = "invalid-email", }; - var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } @@ -48,11 +48,12 @@ public sealed class PaymentTests(TestApi api) : IClassFixture var request = new { + name = "Test User", amount = 5000, email = "test@test.com", }; - var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(HttpStatusCode.OK); }