diff --git a/base-account/base-pay-amazon/checkout-app/package-lock.json b/base-account/base-pay-amazon/checkout-app/package-lock.json index 27cf31dc..60996185 100644 --- a/base-account/base-pay-amazon/checkout-app/package-lock.json +++ b/base-account/base-pay-amazon/checkout-app/package-lock.json @@ -1199,7 +1199,7 @@ "version": "19.1.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1883,6 +1883,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -2517,7 +2530,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4672,6 +4685,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -5206,13 +5232,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5518,6 +5544,19 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6376,19 +6415,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6536,7 +6562,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/base-account/base-pay-amazon/checkout-app/src/app/checkout/page.tsx b/base-account/base-pay-amazon/checkout-app/src/app/checkout/page.tsx index 34ae291f..efe7264c 100644 --- a/base-account/base-pay-amazon/checkout-app/src/app/checkout/page.tsx +++ b/base-account/base-pay-amazon/checkout-app/src/app/checkout/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams } from 'next/navigation'; import { BasePayButton } from '@base-org/account-ui/react'; import { pay } from '@base-org/account'; +import { retryOperation, isRetryableError } from '@/lib/retry'; interface ProductInfo { asin: string; @@ -44,6 +45,8 @@ export default function CheckoutPage() { const [orderComplete, setOrderComplete] = useState(false); const [orderResult, setOrderResult] = useState(null); const [loading, setLoading] = useState(false); + const [retryCount, setRetryCount] = useState(0); + const [retryMessage, setRetryMessage] = useState(null); useEffect(() => { const asin = searchParams.get('asin'); @@ -67,116 +70,162 @@ export default function CheckoutPage() { return; } - try { - setLoading(true); - - console.log('Starting payment with product:', product); - console.log('Payment amount:', product.price.toString()); - - const paymentConfig = { - amount: product.price.toString(), - to: '0x0B14a7aE11B1651aF832DBC282dD1E020E893c4d', - payerInfo: { - requests: [ - { type: 'physicalAddress', required: true }, - { type: 'email', required: false }, - { type: 'phoneNumber', required: false }, - { type: 'name', required: false } - ] - }, - testnet: true - }; - - console.log('Payment config:', paymentConfig); - - const payment = await pay(paymentConfig); + setLoading(true); + setRetryCount(0); + setRetryMessage(null); + + const paymentConfig = { + amount: product.price.toString(), + to: '0x0B14a7aE11B1651aF832DBC282dD1E020E893c4d', + payerInfo: { + requests: [ + { type: 'physicalAddress', required: true }, + { type: 'email', required: false }, + { type: 'phoneNumber', required: false }, + { type: 'name', required: false } + ] + }, + testnet: true + }; - console.log('Payment successful:', payment); - - // Extract user data from payment.payerInfoResponses - if (payment?.payerInfoResponses) { - const responses = payment.payerInfoResponses; - const userData = { - email: responses.email, - phone: responses.phoneNumber ? { - number: responses.phoneNumber.number, - countryCode: responses.phoneNumber.country - } : undefined, - address: responses.physicalAddress ? { - address1: responses.physicalAddress.address1, - address2: responses.physicalAddress.address2, - city: responses.physicalAddress.city, - state: responses.physicalAddress.state, - postalCode: responses.physicalAddress.postalCode, - country: responses.physicalAddress.countryCode, - name: { - firstName: responses.physicalAddress.name?.firstName || responses.name?.firstName, - familyName: responses.physicalAddress.name?.familyName || responses.name?.familyName - } - } : undefined, - name: responses.name ? { - firstName: responses.name.firstName, - lastName: responses.name.familyName - } : undefined - }; + const retryResult = await retryOperation( + async () => { + console.log('Starting payment with product:', product); + console.log('Payment amount:', product.price.toString()); + console.log('Payment config:', paymentConfig); - console.log('Extracted user data:', userData); - setUserData(userData); - setShowConfirmation(true); - } else { - console.log('No payerInfoResponses found in payment response'); + const payment = await pay(paymentConfig); + console.log('Payment successful:', payment); + + // Extract user data from payment.payerInfoResponses + if (payment?.payerInfoResponses) { + const responses = payment.payerInfoResponses; + const userData = { + email: responses.email, + phone: responses.phoneNumber ? { + number: responses.phoneNumber.number, + countryCode: responses.phoneNumber.country + } : undefined, + address: responses.physicalAddress ? { + address1: responses.physicalAddress.address1, + address2: responses.physicalAddress.address2, + city: responses.physicalAddress.city, + state: responses.physicalAddress.state, + postalCode: responses.physicalAddress.postalCode, + country: responses.physicalAddress.countryCode, + name: { + firstName: responses.physicalAddress.name?.firstName || responses.name?.firstName || '', + familyName: responses.physicalAddress.name?.familyName || responses.name?.familyName || '' + } + } : undefined, + name: responses.name ? { + firstName: responses.name.firstName, + lastName: responses.name.familyName + } : undefined + }; + + console.log('Extracted user data:', userData); + setUserData(userData); + setShowConfirmation(true); + } else { + console.log('No payerInfoResponses found in payment response'); + } + + setPaymentComplete(true); + return payment; + }, + { + maxAttempts: 3, + baseDelay: 2000, + maxDelay: 8000, + backoffMultiplier: 2 } - - setPaymentComplete(true); - - } catch (error: any) { + ); + + if (retryResult.success) { + console.log(`Payment succeeded after ${retryResult.attempts} attempt(s)`); + } else { + const error = retryResult.error!; const errorMessage = error?.message || error?.toString() || 'Unknown error occurred'; - console.error('Payment failed with error:', error); + + console.error('Payment failed after all retries:', error); console.error('Error type:', typeof error); console.error('Error keys:', error ? Object.keys(error) : 'No error object'); + + // Check if error is retryable for user feedback + if (isRetryableError(error)) { + setRetryMessage(`Payment failed after ${retryResult.attempts} attempts. This appears to be a temporary issue. Please try again.`); + } else { + setRetryMessage(`Payment failed: ${errorMessage}`); + } + alert('Payment failed: ' + errorMessage); - } finally { - setLoading(false); } + + setLoading(false); }; const handleConfirmPurchase = async () => { if (!product || !userData) return; - try { - setLoading(true); - - const response = await fetch('/api/crossmint-order', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - asin: product.asin, - price: product.price, - title: product.title, - userData: userData, - payerAddress: '0x0B14a7aE11B1651aF832DBC282dD1E020E893c4d' - }), - }); + setLoading(true); + setRetryMessage(null); + + const retryResult = await retryOperation( + async () => { + const response = await fetch('/api/crossmint-order', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + asin: product.asin, + price: product.price, + title: product.title, + userData: userData, + payerAddress: '0x0B14a7aE11B1651aF832DBC282dD1E020E893c4d' + }), + }); - if (!response.ok) { - throw new Error('Failed to create order'); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`Order creation failed: ${errorData.error || 'Unknown error'}`); + } + + const result = await response.json(); + console.log('Order created:', result); + + setShowConfirmation(false); + setOrderComplete(true); + setOrderResult(result); + + return result; + }, + { + maxAttempts: 3, + baseDelay: 1500, + maxDelay: 6000, + backoffMultiplier: 2 } + ); - const result = await response.json(); - console.log('Order created:', result); + if (retryResult.success) { + console.log(`Order creation succeeded after ${retryResult.attempts} attempt(s)`); + } else { + const error = retryResult.error!; + console.error('Order creation failed after all retries:', error); - setShowConfirmation(false); - setOrderComplete(true); - setOrderResult(result); + // Check if error is retryable for user feedback + if (isRetryableError(error)) { + setRetryMessage(`Order creation failed after ${retryResult.attempts} attempts. This appears to be a temporary issue. Please try again.`); + } else { + setRetryMessage(`Order failed: ${error.message}`); + } - } catch (error: any) { - console.error('Order creation failed:', error); alert('Order failed: ' + error.message); - } finally { - setLoading(false); } + + setLoading(false); }; if (orderComplete && orderResult) { @@ -268,6 +317,21 @@ export default function CheckoutPage() { )} + {retryMessage && ( +
+
+
+ + + +
+
+

{retryMessage}

+
+
+
+ )} +
- +
+ +
+ + {retryMessage && ( +
+
+
+ + + +
+
+

{retryMessage}

+
+
+
+ )} )} diff --git a/base-account/base-pay-amazon/checkout-app/src/lib/retry.ts b/base-account/base-pay-amazon/checkout-app/src/lib/retry.ts new file mode 100644 index 00000000..b53026dd --- /dev/null +++ b/base-account/base-pay-amazon/checkout-app/src/lib/retry.ts @@ -0,0 +1,92 @@ +/** + * Retry utility for handling failed operations with exponential backoff + */ + +export interface RetryOptions { + maxAttempts?: number; + baseDelay?: number; + maxDelay?: number; + backoffMultiplier?: number; +} + +export interface RetryResult { + success: boolean; + data?: T; + error?: Error; + attempts: number; +} + +/** + * Retries an async operation with exponential backoff + * @param operation The async operation to retry + * @param options Retry configuration options + * @returns Promise with retry result + */ +export async function retryOperation( + operation: () => Promise, + options: RetryOptions = {} +): Promise> { + const { + maxAttempts = 3, + baseDelay = 1000, + maxDelay = 10000, + backoffMultiplier = 2 + } = options; + + let lastError: Error; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const data = await operation(); + return { + success: true, + data, + attempts: attempt + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry on the last attempt + if (attempt === maxAttempts) { + break; + } + + // Calculate delay with exponential backoff + const delay = Math.min( + baseDelay * Math.pow(backoffMultiplier, attempt - 1), + maxDelay + ); + + console.log(`Attempt ${attempt} failed, retrying in ${delay}ms...`, lastError.message); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + return { + success: false, + error: lastError!, + attempts: maxAttempts + }; +} + +/** + * Determines if an error is retryable based on common patterns + * @param error The error to check + * @returns True if the error should trigger a retry + */ +export function isRetryableError(error: Error): boolean { + const retryablePatterns = [ + /network/i, + /timeout/i, + /connection/i, + /rate limit/i, + /temporary/i, + /server error/i, + /502/i, + /503/i, + /504/i + ]; + + const errorMessage = error.message.toLowerCase(); + return retryablePatterns.some(pattern => pattern.test(errorMessage)); +}