Skip to content

Commit 8fc3fe1

Browse files
committed
feat: Enhance payment processing with loading states and error handling
1 parent 5e33b45 commit 8fc3fe1

File tree

6 files changed

+153
-68
lines changed

6 files changed

+153
-68
lines changed

frontend/src/App.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import React from 'react'
21
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
32
import { AuthProvider } from './hooks/useAuth'
43
import Login from './components/Login'

frontend/src/components/CheckoutPage.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Elements } from '@stripe/react-stripe-js'
33
import stripePromise from '../services/stripe'
44
import PaymentForm from './PaymentForm'
55
import { usePayment, PaymentIntent } from '../hooks/usePayment'
6+
import api from '../services/api'
67

78
interface CartItem {
89
id: number
@@ -33,6 +34,7 @@ const CheckoutPage: React.FC = () => {
3334
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null)
3435
const [currentStep, setCurrentStep] = useState<'cart' | 'payment' | 'processing' | 'success' | 'error'>('cart')
3536
const [error, setError] = useState<string>('')
37+
const [loadingCart, setLoadingCart] = useState(true)
3638

3739
const { loading, initiatePayment, error: paymentError, clearError } = usePayment()
3840

@@ -42,19 +44,21 @@ const CheckoutPage: React.FC = () => {
4244
}, [])
4345

4446
const loadCart = async () => {
47+
setLoadingCart(true)
4548
try {
46-
const response = await fetch('/api/cart')
47-
const data = await response.json()
49+
const response = await api.get('/cart')
4850

49-
if (data.success) {
50-
setCart(data.data)
51+
if (response.data.success) {
52+
setCart(response.data.data)
5153
} else {
5254
setError('Failed to load cart')
5355
setCurrentStep('error')
5456
}
5557
} catch (err: any) {
5658
setError('Failed to load cart')
5759
setCurrentStep('error')
60+
} finally {
61+
setLoadingCart(false)
5862
}
5963
}
6064

@@ -74,7 +78,7 @@ const CheckoutPage: React.FC = () => {
7478
}
7579
}
7680

77-
const handlePaymentSuccess = (paymentIntentId: string) => {
81+
const handlePaymentSuccess = (_paymentIntentId: string) => {
7882
setCurrentStep('processing')
7983
// The webhook will handle the actual order creation
8084
// We'll redirect to success page after a brief delay
@@ -182,6 +186,29 @@ const CheckoutPage: React.FC = () => {
182186
)
183187
}
184188

189+
if (loadingCart) {
190+
return (
191+
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
192+
<div className="max-w-md w-full space-y-8">
193+
<div className="text-center">
194+
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-blue-100">
195+
<svg className="animate-spin h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24">
196+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
197+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
198+
</svg>
199+
</div>
200+
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
201+
Loading Cart...
202+
</h2>
203+
<p className="mt-2 text-sm text-gray-600">
204+
Please wait while we load your cart items.
205+
</p>
206+
</div>
207+
</div>
208+
</div>
209+
)
210+
}
211+
185212
if (!cart || cart.items.length === 0) {
186213
return (
187214
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">

frontend/src/components/PaymentForm.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
2222
}) => {
2323
const stripe = useStripe()
2424
const elements = useElements()
25-
const { loading, confirmPayment, error, clearError } = usePayment()
25+
const { loading, error, clearError } = usePayment()
2626

2727
const [paymentElementReady, setPaymentElementReady] = useState(false)
2828
const [processing, setProcessing] = useState(false)
@@ -57,15 +57,30 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
5757
clearError()
5858

5959
try {
60-
// First confirm payment with backend
61-
const paymentConfirmed = await confirmPayment(paymentIntentId)
62-
63-
if (!paymentConfirmed) {
64-
throw new Error('Payment confirmation failed')
60+
// First, confirm the payment with Stripe client-side
61+
const { error: stripeError, paymentIntent } = await stripe.confirmPayment({
62+
elements,
63+
clientSecret,
64+
confirmParams: {
65+
return_url: `${window.location.origin}/checkout/success`,
66+
},
67+
redirect: 'if_required',
68+
})
69+
70+
if (stripeError) {
71+
throw new Error(stripeError.message || 'Payment confirmation failed')
6572
}
6673

67-
// If backend confirmation succeeds, the webhook will handle the rest
68-
onPaymentSuccess(paymentIntentId)
74+
if (paymentIntent?.status === 'succeeded') {
75+
// Payment succeeded immediately (no 3DS required)
76+
onPaymentSuccess(paymentIntentId)
77+
} else if (paymentIntent?.status === 'processing') {
78+
// Payment is processing (3DS completed, waiting for confirmation)
79+
onPaymentSuccess(paymentIntentId)
80+
} else {
81+
// Payment requires further action or failed
82+
throw new Error('Payment was not completed successfully')
83+
}
6984

7085
} catch (err: any) {
7186
const errorMessage = err.message || 'Payment failed'
@@ -98,7 +113,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
98113
mode: 'billing',
99114
allowedCountries: ['US', 'CA', 'AU', 'GB'],
100115
}}
101-
onChange={(event) => {
116+
onChange={() => {
102117
// AddressElement doesn't provide error in the same way as PaymentElement
103118
// Errors are handled through form validation
104119
}}

frontend/src/components/PaymentStatus.tsx

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react'
1+
import React, { useState, useEffect, useRef } from 'react'
22
import { usePayment } from '../hooks/usePayment'
33

44
interface PaymentStatusProps {
@@ -21,10 +21,18 @@ const PaymentStatus: React.FC<PaymentStatusProps> = ({
2121
const [error, setError] = useState<string>('')
2222

2323
const { checkPaymentStatus } = usePayment()
24+
const timeoutRef = useRef<number | null>(null)
25+
const pollingAttemptsRef = useRef(0)
2426

2527
useEffect(() => {
28+
// Reset polling when paymentIntentId changes
29+
setPollingAttempts(0)
30+
setPaymentStatus('pending')
31+
setError('')
32+
pollingAttemptsRef.current = 0
33+
2634
const pollPaymentStatus = async () => {
27-
if (pollingAttempts >= maxPollingAttempts) {
35+
if (pollingAttemptsRef.current >= maxPollingAttempts) {
2836
setError('Payment confirmation timeout. Please contact support.')
2937
onPaymentError('Payment confirmation timeout')
3038
return
@@ -43,9 +51,8 @@ const PaymentStatus: React.FC<PaymentStatusProps> = ({
4351

4452
case 'processing':
4553
// Continue polling
46-
setTimeout(() => {
47-
setPollingAttempts(prev => prev + 1)
48-
}, pollingInterval)
54+
pollingAttemptsRef.current += 1
55+
timeoutRef.current = setTimeout(pollPaymentStatus, pollingInterval)
4956
break
5057

5158
case 'requires_payment_method':
@@ -69,33 +76,40 @@ const PaymentStatus: React.FC<PaymentStatusProps> = ({
6976
onPaymentError(status.last_payment_error.message || 'Payment failed')
7077
} else {
7178
// Continue polling for unknown status
72-
setTimeout(() => {
73-
setPollingAttempts(prev => prev + 1)
74-
}, pollingInterval)
79+
pollingAttemptsRef.current += 1
80+
timeoutRef.current = setTimeout(pollPaymentStatus, pollingInterval)
7581
}
7682
}
7783
} else {
7884
// Continue polling if no status received
79-
setTimeout(() => {
80-
setPollingAttempts(prev => prev + 1)
81-
}, pollingInterval)
85+
pollingAttemptsRef.current += 1
86+
timeoutRef.current = setTimeout(pollPaymentStatus, pollingInterval)
8287
}
8388
} catch (err: any) {
8489
setError('Failed to check payment status')
8590
onPaymentError('Failed to check payment status')
8691

8792
// Continue polling on error
88-
setTimeout(() => {
89-
setPollingAttempts(prev => prev + 1)
90-
}, pollingInterval)
93+
pollingAttemptsRef.current += 1
94+
timeoutRef.current = setTimeout(pollPaymentStatus, pollingInterval)
9195
}
9296
}
9397

9498
// Start polling after initial delay
95-
const timeoutId = setTimeout(pollPaymentStatus, 1000)
99+
timeoutRef.current = setTimeout(pollPaymentStatus, 1000)
96100

97-
return () => clearTimeout(timeoutId)
98-
}, [paymentIntentId, pollingAttempts, maxPollingAttempts, pollingInterval, checkPaymentStatus, onPaymentSuccess, onPaymentError])
101+
return () => {
102+
if (timeoutRef.current) {
103+
clearTimeout(timeoutRef.current)
104+
timeoutRef.current = null
105+
}
106+
}
107+
}, [paymentIntentId, maxPollingAttempts, pollingInterval, checkPaymentStatus, onPaymentSuccess, onPaymentError])
108+
109+
// Update polling attempts display
110+
useEffect(() => {
111+
setPollingAttempts(pollingAttemptsRef.current)
112+
}, [pollingAttemptsRef.current])
99113

100114
const getStatusMessage = () => {
101115
switch (paymentStatus) {

frontend/src/components/PaymentSuccess.tsx

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useState } from 'react'
22
import { useSearchParams, useNavigate } from 'react-router-dom'
3+
import api from '../services/api'
34

45
interface OrderData {
56
id: number
@@ -47,39 +48,61 @@ const PaymentSuccess: React.FC = () => {
4748

4849
const poll = async () => {
4950
try {
50-
// This would typically call an API endpoint to get order by payment intent
51-
// For now, we'll simulate the polling
52-
await new Promise(resolve => setTimeout(resolve, 1000))
51+
// Try to find order by payment intent ID
52+
// This would typically call an API endpoint like /api/orders/by-payment-intent/{paymentIntentId}
53+
// For now, we'll poll the payment status and assume order creation
54+
const response = await api.get(`/checkout/payment-status/${paymentIntentId}`)
5355

54-
// Simulate order creation after a few attempts
55-
if (attempts >= 2) {
56-
// Mock order data
57-
setOrderData({
58-
id: 1,
59-
order_number: 'ORD-20250119-ABC12345',
60-
total_amount: 299.99,
61-
status: 'paid',
62-
created_at: new Date().toISOString(),
63-
items: [
64-
{
65-
id: 1,
66-
quantity: 1,
67-
price_at_time: 299.99,
68-
product: {
69-
name: 'Solar Panel Pro 300W',
70-
slug: 'solar-panel-pro-300w'
56+
if (response.data.success) {
57+
const paymentStatus = response.data.data
58+
59+
if (paymentStatus.status === 'succeeded') {
60+
// Payment succeeded, try to get order details
61+
// Since we don't have a specific order lookup endpoint yet,
62+
// we'll show a generic success message
63+
setOrderData({
64+
id: 1,
65+
order_number: 'ORD-PENDING',
66+
total_amount: paymentStatus.amount / 100, // Convert from cents
67+
status: 'paid',
68+
created_at: new Date().toISOString(),
69+
items: [
70+
{
71+
id: 1,
72+
quantity: 1,
73+
price_at_time: paymentStatus.amount / 100,
74+
product: {
75+
name: 'Order Processed',
76+
slug: 'order-processed'
77+
}
7178
}
72-
}
73-
]
74-
})
75-
setLoading(false)
79+
]
80+
})
81+
setLoading(false)
82+
} else if (attempts >= maxAttempts) {
83+
setError('Order confirmation timeout. Please check your email for order details.')
84+
setLoading(false)
85+
} else {
86+
attempts++
87+
setTimeout(poll, 2000) // Poll every 2 seconds
88+
}
7689
} else {
7790
attempts++
78-
setTimeout(poll, 1000)
91+
if (attempts >= maxAttempts) {
92+
setError('Failed to confirm payment status')
93+
setLoading(false)
94+
} else {
95+
setTimeout(poll, 2000)
96+
}
7997
}
8098
} catch (err: any) {
81-
setError('Failed to retrieve order information')
82-
setLoading(false)
99+
attempts++
100+
if (attempts >= maxAttempts) {
101+
setError('Failed to retrieve order information')
102+
setLoading(false)
103+
} else {
104+
setTimeout(poll, 2000)
105+
}
83106
}
84107
}
85108

@@ -88,22 +111,22 @@ const PaymentSuccess: React.FC = () => {
88111

89112
const loadOrderData = async (orderId: string) => {
90113
try {
91-
// This would call the orders API endpoint
92-
// For now, we'll use mock data
114+
// This would call the orders API endpoint: /api/orders/{orderId}
115+
// For now, we'll show a message that the order is being processed
93116
setOrderData({
94117
id: parseInt(orderId),
95-
order_number: 'ORD-20250119-ABC12345',
96-
total_amount: 299.99,
97-
status: 'paid',
118+
order_number: 'ORD-PENDING',
119+
total_amount: 0,
120+
status: 'processing',
98121
created_at: new Date().toISOString(),
99122
items: [
100123
{
101124
id: 1,
102125
quantity: 1,
103-
price_at_time: 299.99,
126+
price_at_time: 0,
104127
product: {
105-
name: 'Solar Panel Pro 300W',
106-
slug: 'solar-panel-pro-300w'
128+
name: 'Order Being Processed',
129+
slug: 'order-processing'
107130
}
108131
}
109132
]

frontend/src/hooks/usePayment.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export const usePayment = () => {
8383
}
8484
}, [])
8585

86-
const checkPaymentStatus = useCallback(async (paymentIntentId: string): Promise<PaymentStatus | null> => {
86+
const checkPaymentStatus = useCallback(async (paymentIntentId: string, retryCount = 0): Promise<PaymentStatus | null> => {
8787
try {
8888
const response = await api.get(`/checkout/payment-status/${paymentIntentId}`)
8989

@@ -94,6 +94,13 @@ export const usePayment = () => {
9494
}
9595
} catch (err: any) {
9696
console.error('Failed to check payment status:', err)
97+
98+
// Retry logic for network failures
99+
if (retryCount < 3 && (err.code === 'NETWORK_ERROR' || err.response?.status >= 500)) {
100+
await new Promise(resolve => setTimeout(resolve, Math.pow(2, retryCount) * 1000)) // Exponential backoff
101+
return checkPaymentStatus(paymentIntentId, retryCount + 1)
102+
}
103+
97104
return null
98105
}
99106
}, [])

0 commit comments

Comments
 (0)