From 4e18abfa3dcaca6dc019b65746d62e65897c8c04 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:12:08 -0600 Subject: [PATCH] feat(client): add payment components --- .../public/espresso.png | Bin 0 -> 3915 bytes .../src/CheckoutForm.tsx | 48 +++++ .../src/PaymentCard.tsx | 44 ++++ .../src/PaymentConfirmationCard.tsx | 100 +++++++++ .../src/PaymentForm.tsx | 200 ++++++++++++++++++ .../src/PreviousPaymentsList.tsx | 130 ++++++++++++ 6 files changed, 522 insertions(+) create mode 100644 src/GroundsForSupport.Client/public/espresso.png create mode 100644 src/GroundsForSupport.Client/src/CheckoutForm.tsx create mode 100644 src/GroundsForSupport.Client/src/PaymentCard.tsx create mode 100644 src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx create mode 100644 src/GroundsForSupport.Client/src/PaymentForm.tsx create mode 100644 src/GroundsForSupport.Client/src/PreviousPaymentsList.tsx diff --git a/src/GroundsForSupport.Client/public/espresso.png b/src/GroundsForSupport.Client/public/espresso.png new file mode 100644 index 0000000000000000000000000000000000000000..a8ec147ef4cb82ccc46a0d88c2b2c5a858da9d13 GIT binary patch literal 3915 zcmbtXdo+}57k?*m3%MN$X__$ZGiF@IWH53s9QR8qW9A*^7&BvLFocAnTncd_rE}bl z)g?ptyUpy&d;siS!UX z0*M{~>TyCCd^7-t27rMS8VLr0_|V=ov)v5R7YZc$F| z#He7R2?=g)CT7aP@(DsfHUY{Bq0(4b4i3J-i{<}bcO&3@5R*j4x?0(MgYYdJJc!L^ zU=fIjhzPw1v>u%qh|o7NF+m_v2oy?}kI-dB(%1x!E{(OD&macf;IIN&L?(s7rqF57 zbxuM6J&cWm!^NQAsPSwzlM?WqoyO85Y@|^eY~VC$ZMe zuba4mp&Q0mi%Qwpi9ukpK=()nh=aR=EP5D|2yQT}2Yn;i%LEB*kYt8JqKtHr`npI1 zQ^db5t?%)7fHjA0Pckzml8I;#Nzx@58=!Oz42dM&0AmA!uD&4}WsD{V7#U)WzY~28 z`6rD{0H4JWWr#8|G|@NEH#RZEAioD}EdCSkP7fmnt*2s!`VRZsw}CZ9tXG#x`C60> z<6Bj}M|>^WSKtqA`R`jrB7ScZBaBJikN}B@0I6UINMo-zN&j1uNJK1~!lr_Mw+K&V z|FcDW?O~||S|AS2(ItUoLKu||H}jxvgpr^mkVOfkL3N?}{~@t&T&9R0w)sEOV*DWP zb@^@Fv323`7x+1$zvaZYu=p@`5S?kpA~QiSfX<}x+x&x@FZqqa|4+r`-^uUQ{l_Ud z1jY~Y=fkiYvZXWK>2#{uUIHzg0M&2>X(W&dGBu$jIuU1uw>2UC5{$+h2SUSGAQR_- zw+wP{MzUzZF3so6Q27@DXNvfD`u~{H*DU$dfj=h@-=_)R z_&$&Lr@_IQ{AmN*JX0zN14M# z*Xe?cB=^rjDz4M2Io%mLom!nPxq|kKn=EwXkMur_d?Ik>wN&*>wJm}XNl3?2}Wl8W1|&{bL*br4q-7KR3P0|0YTtwlI4xx?+o1X|SdfmB@X zF9}*gYLp?`<&*pU(E0C<)t4ftv(V3%#izaU zn$b8?$bO1vvxIisa^dW9lWdau9V zDf)Dd*TI&KlgQresNUemvkrfD;~y++g|yb{43cY_o;@*gk@n$SeU?(_x1M7EV0k?PESh~!G!MMDMjkV_fE+-ua=rvG1&FFpC8%BzCJ~3b5iMV%GOu-lKw50!iVq4nubko?Ji-L-Z%GRVp zjFVEDZzjp7Rbc(KS<^e{Sv|svgLx4<`;+4A2m|2*HQE`Ixd=q(Y}bz$K^x zr9yiNU^eJf4sx&e zp@oF}KD|R(xpB{Hv!frDm)~KGy*_6Y%wq$`ZSTkkrZ$Acetfq$KGxLVpH(>TVj!7I zDC=*0DS;b0=u4A5l`yXlWVYU1z|!Uh+uMiVzaJm_ICt;9dCLcGcWGa$KG+O7Q5CPI znAYy`hqUx>!@qo3Dh!s5@yW>W`1oi-WNk&H{OZ+6zVKTrd{gTk*TCSw)&4#e ziKJigQg)h4j&VTxaS7+grf)))Luv()2}U|sb+TNQ+{%sTO{1%07LEn(sXE+jYQ9I? z>qvGl`JS#7@KQxNdTHu>c;MvQv0LTF^n1U;*Osy_gsZZjh7Sy0zV(<*J>U8!fVyMz zp#QnIw!_DLLJZedDt3T&ftQa&J(^uXIy(mXP%lPrA!%JTFu`F{Z4;7a#}~{G$Pxy} z-xb!^DHXKbp4=)FU@tO-ys_m1@6F&y#rfHbvt2qLRCg~w z@%A7X>eSJG?C6xs+&l1U|Dt2Rn4bKVb6$DW3M5Ql#K|7%y;uDH@$}8ZM@LH7&l-1V z4=sAAOP5?dHQQ>LTxjRu6T!HeHhaEhDdx8}0flBEgB;>;x?{bY)n_ zyog-E#DSQ#DpjZKvobv7Kx6gqp`<#S3A&!{Aa*wPh?>dvz0o!u&_oCL!0^`7_ekukKINgYSOzART^(bx6L5uw&^M}yk2DQ9h9`x5YV zQWhdsf|jGz$pN_e0`el~OXAtL9kppj)&@;vp$s~)_^go)v>Ed(s_Dqg)dTIxB#tO_ z$Mu{(fA1P{F-h)3TP&hm-0-?A&2PD|dQB9jRd!B-ckhc~gGVtnNq8)2b>VH_a`&(1 zc^}5sW|9_fuNvLW0s6=(23KA3h?MxBi)8}(t8)xTb5n&YwP(|uR!2*-@EQ2S0-o7l zBtA6VD>h$te(UTcIh0Vn?PcJ^!OI%eg1&;AhM~2P;$2xf+aJ^$o4N%ns_&H3L4_YtDxCFu1N? zwR_*`$jpl2YO#jRp$}#ygauS@DyYW_bd(oEEsqyTz!*NZN){m&inRJB=&9`cs>zPs zSRC%;9sQn+Jw2Ptwft=G&y1B^)gSw!E!jr&_P)=u4ybmhvy9h0e{qsfVgSOSHTeQU z<*t2z=swNd{wA47GZjzJuz0{_^^bwS3?H2Lsxn#-a~!ujmf@$R(j$K2HW1vZkzJ3e zsTi?7dgEsEV%z9Y&k5ErZ|3-o)ymwHju-dAfbiATZSwL>gU>M@aIUQwl6cCnM98%_ z7eety*$LU)ZZD!dI&^av_4;_w>z0@;sbD?oV_vtfNm&~*x!+xL*t;R}^L*T~P8pl{ z&dueuo_k?3Mc(MP=e@ci?nsr>Hu&O<9(+Z^a)qS<_EorH!opDY99ifQW*1CGJ`=#J ziX4Z^@U?^5H*{_F#0wFi4XG@V;GncOG54p}R5&xkDIl*X5MH_xg-d_F=d{WeF4{&G zfWV}Xy97{D3W6gymoCK+agJ`>Mak9GG<~Z2o3{N4y&X=P&Phv63D4etew9}5k0J5W z#tw3;g0nL$_MNFdK5IhjJv{Mbwg6ITyK{?Cy^k;h4oW-&dr%$672=Vbc#*Lz!s0;6HlI9{ zf}oB!mP^i`yZdOl^~Cl|5uw*2kBrt_xa}_m7ggE~meip}stIIy%g1ZJg~L}26@P7; zynfjHp@Z`>4;9!va+2jfPz;=v%^Aj|UvFrtzjUUMs(;ha@82dVML)(z1 z3oGNzk^O6nw#9PNyB^g!_4c&;l|H$Cx9(BT`^msmh`g#_-%>RH!wpSTs~bdR*~`E~ zGw;?sRFuy6!IVKw~j%F#s?Ue<#o{CK^o)QK$5;r#t zX_-x@A35CpMZ{L1iGvsucjG8 RFV_F=+FLtWRa*MT{|jvukO2Sy literal 0 HcmV?d00001 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} + +
+
+ +