diff --git a/src/GroundsForSupport.Client/public/espresso.png b/src/GroundsForSupport.Client/public/espresso.png new file mode 100644 index 0000000..a8ec147 Binary files /dev/null and b/src/GroundsForSupport.Client/public/espresso.png differ diff --git a/src/GroundsForSupport.Client/src/CheckoutForm.tsx b/src/GroundsForSupport.Client/src/CheckoutForm.tsx new file mode 100644 index 0000000..6e7cac6 --- /dev/null +++ b/src/GroundsForSupport.Client/src/CheckoutForm.tsx @@ -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(false); + const stripe = useStripe(); + const elements = useElements(); + const isLoading = !stripe || !elements; + + async function handleSubmit(e: React.FormEvent) { + 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 ( +
+ + + + ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentCard.tsx b/src/GroundsForSupport.Client/src/PaymentCard.tsx new file mode 100644 index 0000000..5179570 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentCard.tsx @@ -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 ( +
+
+
+ Avatar +
+
+
+
{payment.name}
+
{formattedDate}
+
+
+
${formattedPayment.toFixed(2)}
+
+
+
+ {payment.message &&
{payment.message}
} +
+ ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx b/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx new file mode 100644 index 0000000..0290688 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx @@ -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({ 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 ( +
+
+ Checking payment status... +
+ ); + } + + return ( +
+ {data.status === 'error' ? ( + <> +

Payment Error

+

There was an error processing your payment: {data.error}

+ + ) : ( + <> +

Thank you!

+

Your payment was successful. I appreciate your support!

+ + )} + +
+ ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentForm.tsx b/src/GroundsForSupport.Client/src/PaymentForm.tsx new file mode 100644 index 0000000..9b46693 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentForm.tsx @@ -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(''); + const [amount, setAmount] = useState(''); + const [message, setMessage] = useState(''); + const [email, setEmail] = useState(''); + const [errors, setErrors] = useState<{ + name?: string; + amount?: string; + email?: string; + message?: string; + }>({}); + + const nameInputRef = useRef(null); + const amountInputRef = useRef(null); + const messageTextAreaRef = useRef(null); + const emailInputRef = useRef(null); + + function handleNameInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, name: undefined })); + setName(event.target.value); + } + + function handleAmountInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, amount: undefined })); + const value = parseInt(event.target.value); + setAmount(isNaN(value) ? '' : value); + } + + function handleMessageInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, message: undefined })); + setMessage(event.target.value); + } + + function handleEmailInput(event: React.ChangeEvent) { + 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) { + 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 ( +
+
+ + + + {errors.name} + +
+
+ + + + {errors.amount} + +
+
+ +