feat(client): add payment components
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user