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