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 {
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;
}
+91 -89
View File
@@ -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<number | ''>('');
const [email, setEmail] = useState<string>('');
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<string | undefined>(clientSecretFromUrl ?? undefined);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const amountInputRef = useRef<HTMLInputElement>(null);
const emailInputRef = useRef<HTMLInputElement>(null);
async function handleDonationFormSubmit(formData: {
name: string;
amount: number;
message?: string;
email?: string;
}) {
setIsSubmitting(true);
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) {
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<HTMLInputElement>) {
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();
}
}
return isValid;
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const isValid = validateForm();
if (isValid === false) {
if (!res.ok) {
console.error('Failed to create payment intent');
alert('An error occurred while creating the payment. Please try again.');
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}`);
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 (
@@ -72,49 +65,58 @@ function App() {
<h1>Stevan Freeborn</h1>
<p>
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>
</div>
</header>
<main>
<form
onSubmit={handleSubmit}
noValidate
>
<div className='group'>
<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}
<section className='payment'>
{secret === undefined ? (
<PaymentForm
onValidSubmit={handleDonationFormSubmit}
isSubmitting={isSubmitting}
/>
<span className='error-message'>{errors.amount}</span>
</div>
<div className='group'>
<label htmlFor='email'>Email</label>
<input
ref={emailInputRef}
id='email'
type='email'
aria-describedby='emailErrorMessage'
aria-invalid={errors.email ? 'true' : 'false'}
value={email}
onInput={handleEmailInput}
/>
<span
id='emailErrorMessage'
className='error-message'
) : (
<Elements
options={{
clientSecret: secret,
loader: 'auto',
appearance: {
theme: 'stripe',
disableAnimations: true,
variables: {
colorBackground: '#181818',
colorPrimary: '#E4E4E4',
colorText: '#E4E4E4',
fontFamily: 'CaskaydiaCove NFM, monospace',
fontSizeBase: '16px',
borderRadius: '0.25rem',
colorDanger: '#ff6b6b',
},
rules: {
'.Label': {
fontWeight: '700',
},
'.Input': {
backgroundColor: '#282828',
border: '1px solid #444',
padding: '0.5rem',
},
},
},
}}
stripe={stripe}
>
{errors.email}
</span>
</div>
<button type='submit'>Buy</button>
</form>
{clientSecretFromUrl ? (
<PaymentConfirmationCard clientSecret={clientSecretFromUrl} />
) : (
<CheckoutForm />
)}
</Elements>
)}
</section>
{clientSecretFromUrl === null ? <PreviousPaymentsList /> : null}
</main>
</>
);
@@ -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;
}
@@ -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(<App />);
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 () => {
@@ -20,7 +20,7 @@ public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
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<TestApi>
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<TestApi>
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);
}