feat(client): add payment components

This commit is contained in:
Stevan Freeborn
2026-01-04 21:12:08 -06:00
parent 05686f193f
commit 4e18abfa3d
6 changed files with 522 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -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<boolean>(false);
const stripe = useStripe();
const elements = useElements();
const isLoading = !stripe || !elements;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit}>
<PaymentElement
id='payment-element'
options={{ layout: 'accordion' }}
/>
<button
disabled={isLoading || isSubmitting}
id='submit'
>
<span id='button-text'>Pay</span>
</button>
</form>
);
}
@@ -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 (
<div className='payment-card'>
<div className='header'>
<div className='left'>
<img
src='/espresso.png'
alt='Avatar'
/>
</div>
<div className='right'>
<div className='info'>
<div>{payment.name}</div>
<div className='date'>{formattedDate}</div>
</div>
<div className='details'>
<div className='amount'>${formattedPayment.toFixed(2)}</div>
</div>
</div>
</div>
{payment.message && <div className='message'>{payment.message}</div>}
</div>
);
}
@@ -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<PaymentIntentData>({ 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 });
}
}
fetchPaymentIntent();
return () => {
isMounted = false;
};
}, [stripe, clientSecret]);
if (data.status === 'loading') {
return (
<div className='payment-confirmation-loading'>
<div className='spinner' />
<span>Checking payment status...</span>
</div>
);
}
return (
<div className='payment-confirmation-card'>
{data.status === 'error' ? (
<>
<h2>Payment Error</h2>
<p>There was an error processing your payment: {data.error}</p>
</>
) : (
<>
<h2>Thank you!</h2>
<p>Your payment was successful. I appreciate your support!</p>
</>
)}
<div>
<a
className='return-home-link'
href='/'
>
Return to Home
</a>
</div>
</div>
);
}
@@ -0,0 +1,200 @@
import { useState, useRef } from 'react';
type DonationFormProps = {
onValidSubmit: (data: { name: string; amount: number; message?: string; email?: string }) => void;
isSubmitting?: boolean;
};
export default function PaymentForm({ onValidSubmit, isSubmitting }: DonationFormProps) {
const [name, setName] = useState<string>('');
const [amount, setAmount] = useState<number | ''>('');
const [message, setMessage] = useState<string>('');
const [email, setEmail] = useState<string>('');
const [errors, setErrors] = useState<{
name?: string;
amount?: string;
email?: string;
message?: string;
}>({});
const nameInputRef = useRef<HTMLInputElement>(null);
const amountInputRef = useRef<HTMLInputElement>(null);
const messageTextAreaRef = useRef<HTMLTextAreaElement>(null);
const emailInputRef = useRef<HTMLInputElement>(null);
function handleNameInput(event: React.ChangeEvent<HTMLInputElement>) {
setErrors(prevErrors => ({ ...prevErrors, name: undefined }));
setName(event.target.value);
}
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) {
setErrors(prevErrors => ({ ...prevErrors, amount: undefined }));
const value = parseInt(event.target.value);
setAmount(isNaN(value) ? '' : value);
}
function handleMessageInput(event: React.ChangeEvent<HTMLTextAreaElement>) {
setErrors(prevErrors => ({ ...prevErrors, message: undefined }));
setMessage(event.target.value);
}
function handleEmailInput(event: React.ChangeEvent<HTMLInputElement>) {
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<HTMLFormElement>) {
event.preventDefault();
const isValid = validateForm();
if (isValid === false) {
return;
}
if (amount === '') {
throw new Error('amount after validation should never be an empty string');
}
onValidSubmit({ name, amount, message, email });
}
return (
<form
onSubmit={handleSubmit}
noValidate
>
<div className='group'>
<label htmlFor='name'>Name</label>
<input
ref={nameInputRef}
id='name'
type='text'
required
maxLength={60}
aria-describedby='nameErrorMessage'
aria-invalid={errors.name ? 'true' : 'false'}
value={name}
onInput={handleNameInput}
placeholder='What do I call you?'
/>
<span
id='nameErrorMessage'
className='error-message'
>
{errors.name}
</span>
</div>
<div className='group'>
<label htmlFor='amount'>Amount</label>
<input
ref={amountInputRef}
id='amount'
type='number'
required
min={1}
aria-describedby='amountErrorMessage'
aria-invalid={errors.amount ? 'true' : 'false'}
value={amount}
onInput={handleAmountInput}
placeholder='1'
/>
<span
id='amountErrorMessage'
className='error-message'
>
{errors.amount}
</span>
</div>
<div className='group'>
<label htmlFor='message'>Message</label>
<textarea
id='message'
ref={messageTextAreaRef}
aria-describedby='messageErrorMessage'
aria-invalid={errors.message ? 'true' : 'false'}
value={message}
maxLength={250}
onInput={handleMessageInput}
placeholder='Let me know what I did for you!'
/>
<span
id='messageErrorMessage'
className='error-message'
>
{errors.message}
</span>
</div>
<div className='group'>
<div className='email-label'>
<label htmlFor='email'>Email </label>
<span className='detail'>[if you want a receipt]</span>
</div>
<input
ref={emailInputRef}
id='email'
type='email'
aria-describedby='emailErrorMessage'
aria-invalid={errors.email ? 'true' : 'false'}
value={email}
onInput={handleEmailInput}
placeholder='hello@world.com'
/>
<span
id='emailErrorMessage'
className='error-message'
>
{errors.email}
</span>
</div>
<button
type='submit'
disabled={isSubmitting}
>
Buy
</button>
</form>
);
}
@@ -0,0 +1,130 @@
import { useState, useEffect } from 'react';
import type { Payment } from './PaymentCard';
import PaymentCard from './PaymentCard';
type PaymentPage = {
totalNumberOfPayments: number;
totalNumberOfPages: number;
currentPageNumber: number;
payments: Payment[];
};
type PaymentData =
| {
status: 'loading';
}
| { status: 'error'; message: string }
| {
status: 'success';
pages: PaymentPage[];
};
export default function PreviousPaymentsList() {
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
const [paymentData, setPaymentData] = useState<PaymentData>({ status: 'loading' });
const payments =
paymentData.status === 'success' ? paymentData.pages.flatMap(page => page.payments) : [];
const currentPageNumber =
paymentData.status === 'success'
? paymentData.pages[paymentData.pages.length - 1].currentPageNumber
: 0;
const totalNumberOfPages =
paymentData.status === 'success' ? paymentData.pages[0].totalNumberOfPages : 0;
useEffect(() => {
let isMounted = true;
async function fetchPayments() {
try {
const url = new URL('/payments', import.meta.url);
const res = await fetch(url);
if (!isMounted) return;
if (!res.ok) {
setPaymentData({ status: 'error', message: 'Failed to fetch payments' });
}
const data = await res.json();
setPaymentData({ status: 'success', pages: [data] });
} catch (err) {
if (!isMounted) {
return;
}
console.error(err);
setPaymentData({ status: 'error', message: 'Failed to fetch payments' });
}
}
fetchPayments();
return () => {
isMounted = false;
};
}, []);
async function handleLoadMoreButtonClick() {
if (paymentData.status !== 'success') {
return;
}
setIsLoadingMore(true);
try {
const nextPageNumber = currentPageNumber + 1;
const url = new URL('/payments', import.meta.url);
const queryParams = new URLSearchParams({ pageNumber: nextPageNumber.toString() });
const res = await fetch(`${url.toString()}?${queryParams.toString()}`);
if (!res.ok) {
console.error('Failed to fetch more payments');
alert('Failed to load more payments. Please try again.');
return;
}
const data = await res.json();
setPaymentData({
status: 'success',
pages: [...paymentData.pages, data],
});
} catch (err) {
console.error(err);
alert('Failed to load more payments. Please try again.');
} finally {
setIsLoadingMore(false);
}
}
return paymentData.status === 'loading' ? (
<div>Loading previous donations...</div>
) : paymentData.status === 'error' ? (
<div className='error'>Failed to load previous donations</div>
) : (
<section className='previous-donations'>
{payments.length == 0 ? null : (
<>
<ul>
{payments.map((payment, index) => (
<li key={index}>
<PaymentCard payment={payment} />
</li>
))}
</ul>
{currentPageNumber === totalNumberOfPages ? null : (
<button
className='load-more-button'
type='button'
onClick={handleLoadMoreButtonClick}
disabled={currentPageNumber >= totalNumberOfPages || isLoadingMore}
>
Load More
</button>
)}
</>
)}
</section>
);
}