feat(client): add payment components

This commit is contained in:
Stevan Freeborn
2026-01-04 21:13:08 -06:00
parent 26f163d878
commit 52c6c82886
5 changed files with 316 additions and 110 deletions
+191 -4
View File
@@ -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 { header {
background-color: #181818; background-color: #181818;
flex-direction: column; flex-direction: column;
@@ -25,15 +41,17 @@ header .info {
text-align: center; text-align: center;
} }
header .info p {
text-align: left;
}
form { form {
display: flex;
flex-direction: column;
background-color: #181818; background-color: #181818;
border-radius: 0.25rem; border-radius: 0.25rem;
flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
width: 100%;
max-width: 31.25rem;
padding: 1rem; padding: 1rem;
display: flex;
box-shadow: 0 4px 8px #0000001a; box-shadow: 0 4px 8px #0000001a;
} }
@@ -47,6 +65,17 @@ form label {
font-weight: 700; 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 { form input {
color: #e4e4e4; color: #e4e4e4;
background-color: #282828; background-color: #282828;
@@ -69,6 +98,15 @@ form input[type='number'] {
appearance: textfield; appearance: textfield;
} }
form textarea {
color: #e4e4e4;
background-color: #282828;
border: 1px solid #444;
border-radius: 0.25rem;
padding: 0.5rem;
resize: vertical;
}
.error-message { .error-message {
color: #ff6b6b; color: #ff6b6b;
font-size: 0.875rem; font-size: 0.875rem;
@@ -83,3 +121,152 @@ form button {
font-weight: 700; font-weight: 700;
transition: background-color 0.3s; 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;
}
+91 -89
View File
@@ -1,64 +1,57 @@
import '@/App.css'; 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() { function App() {
const [amount, setAmount] = useState<number | ''>(''); const queryParams = new URLSearchParams(window.location.search);
const [email, setEmail] = useState<string>(''); const clientSecretFromUrl = queryParams.get('payment_intent_client_secret');
const [errors, setErrors] = useState<{ amount?: string; email?: string }>({}); const [secret, setSecret] = useState<string | undefined>(clientSecretFromUrl ?? undefined);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const amountInputRef = useRef<HTMLInputElement>(null); async function handleDonationFormSubmit(formData: {
const emailInputRef = useRef<HTMLInputElement>(null); name: string;
amount: number;
message?: string;
email?: string;
}) {
setIsSubmitting(true);
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) { try {
setErrors((prevErrors) => ({ ...prevErrors, amount: undefined })); const url = new URL('/payments/create-intent', import.meta.url);
const value = parseInt(event.target.value); const res = await fetch(url, {
setAmount(isNaN(value) ? '' : value); 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<HTMLInputElement>) { if (!res.ok) {
setErrors((prevErrors) => ({ ...prevErrors, email: undefined })); console.error('Failed to create payment intent');
setEmail(event.target.value); alert('An error occurred while creating the payment. Please try again.');
}
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();
}
}
return isValid;
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const isValid = validateForm();
if (isValid === false) {
return; return;
} }
// TODO: We will stop displaying our form const data = await res.json();
// and we will initialize the stripe embedded setSecret(data.clientSecret);
// elements and pass along the amount and email } catch (err) {
alert(`Form is valid! Amount: ${amount}, Email: ${email}`); console.error(err);
alert('An error occurred while creating the payment. Please try again.');
} finally {
setIsSubmitting(false);
}
} }
return ( return (
@@ -72,49 +65,58 @@ function App() {
<h1>Stevan Freeborn</h1> <h1>Stevan Freeborn</h1>
<p> <p>
I'm a dad of 2 who enjoys drinking coffee, lifting weights, and solving problems with 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!
</p> </p>
</div> </div>
</header> </header>
<main> <main>
<form <section className='payment'>
onSubmit={handleSubmit} {secret === undefined ? (
noValidate <PaymentForm
> onValidSubmit={handleDonationFormSubmit}
<div className='group'> isSubmitting={isSubmitting}
<label htmlFor='amount'>Amount</label>
<input
ref={amountInputRef}
id='amount'
type='number'
min={1}
aria-describedby='amountErrorMessage'
aria-invalid={errors.amount ? 'true' : 'false'}
value={amount}
onInput={handleAmountInput}
/> />
<span className='error-message'>{errors.amount}</span> ) : (
</div> <Elements
<div className='group'> options={{
<label htmlFor='email'>Email</label> clientSecret: secret,
<input loader: 'auto',
ref={emailInputRef} appearance: {
id='email' theme: 'stripe',
type='email' disableAnimations: true,
aria-describedby='emailErrorMessage' variables: {
aria-invalid={errors.email ? 'true' : 'false'} colorBackground: '#181818',
value={email} colorPrimary: '#E4E4E4',
onInput={handleEmailInput} colorText: '#E4E4E4',
/> fontFamily: 'CaskaydiaCove NFM, monospace',
<span fontSizeBase: '16px',
id='emailErrorMessage' borderRadius: '0.25rem',
className='error-message' colorDanger: '#ff6b6b',
},
rules: {
'.Label': {
fontWeight: '700',
},
'.Input': {
backgroundColor: '#282828',
border: '1px solid #444',
padding: '0.5rem',
},
},
},
}}
stripe={stripe}
> >
{errors.email} {clientSecretFromUrl ? (
</span> <PaymentConfirmationCard clientSecret={clientSecretFromUrl} />
</div> ) : (
<button type='submit'>Buy</button> <CheckoutForm />
</form> )}
</Elements>
)}
</section>
{clientSecretFromUrl === null ? <PreviousPaymentsList /> : null}
</main> </main>
</> </>
); );
@@ -69,12 +69,3 @@ body {
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-color); color: var(--text-color);
} }
main {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
padding-top: 2rem;
}
@@ -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 App from '@/App.tsx';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
@@ -25,8 +25,36 @@ describe('profile info', () => {
describe('form', () => { describe('form', () => {
let user: UserEvent; let user: UserEvent;
const mockFetch = vi.fn();
beforeEach(() => { beforeEach(() => {
user = userEvent.setup(); 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', () => { test('it should have an amount input', () => {
@@ -83,8 +111,6 @@ describe('form', () => {
}); });
test('it should allow form to be submitted without email', async () => { test('it should allow form to be submitted without email', async () => {
const windowSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
const screen = render(<App />); const screen = render(<App />);
const amountInput = screen.getByLabelText('Amount'); const amountInput = screen.getByLabelText('Amount');
@@ -93,7 +119,6 @@ describe('form', () => {
await user.type(amountInput, '10'); await user.type(amountInput, '10');
await user.click(submitButton); await user.click(submitButton);
expect(windowSpy).toHaveBeenCalledWith('Form is valid! Amount: 10, Email: ');
}); });
test('it should validate email if provided', async () => { test('it should validate email if provided', async () => {
@@ -20,7 +20,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
email = string.Empty, 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); response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
} }
@@ -36,7 +36,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
email = "invalid-email", 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); response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
} }
@@ -48,11 +48,12 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
var request = new var request = new
{ {
name = "Test User",
amount = 5000, amount = 5000,
email = "test@test.com", 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); response.StatusCode.Should().Be(HttpStatusCode.OK);
} }