Created
August 28, 2025 13:18
-
-
Save slicksammy/896d34a81133e5b33d15847da66b0847 to your computer and use it in GitHub Desktop.
bt.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useEffect, useRef, useState } from 'react'; | |
| import { PaymentMethod, CheckoutData } from '../../types'; | |
| import { API_ENDPOINTS, BACKEND_URL } from '../../config'; | |
| import { captureException, addBreadcrumb } from '../../utils/sentry'; | |
| import { usePostHogTracking } from '../../hooks/usePostHogTracking'; | |
| import { useBasisTheory, CardNumberElement, CardExpirationDateElement, CardVerificationCodeElement } from '@basis-theory/react-elements'; | |
| interface BasisTheoryCardFormProps { | |
| checkoutData: CheckoutData; | |
| onNewPaymentMethodCreated: (newMethod?: PaymentMethod) => void; | |
| createPath: string; | |
| } | |
| interface BasisTheorySettings { | |
| tokenization_api_key: string; | |
| } | |
| const BasisTheoryCardForm: React.FC<BasisTheoryCardFormProps> = ({ | |
| checkoutData, | |
| onNewPaymentMethodCreated, | |
| createPath, | |
| }) => { | |
| const { trackEvent } = usePostHogTracking(checkoutData); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [saveCard, setSaveCard] = useState(true); | |
| const [errorMessage, setErrorMessage] = useState(""); | |
| const [isFormLoading, setIsFormLoading] = useState(true); | |
| const [isFormValid, setIsFormValid] = useState(false); | |
| const [basisTheorySettings, setBasisTheorySettings] = useState<BasisTheorySettings | null>(null); | |
| const [error, setError] = useState<string>(""); | |
| // State for non-sensitive form fields | |
| const [firstName, setFirstName] = useState(""); | |
| const [lastName, setLastName] = useState(""); | |
| const [postalCode, setPostalCode] = useState(""); | |
| const cardNumberRef = useRef<any>(null); | |
| const cardExpiryRef = useRef<any>(null); | |
| const cardCvcRef = useRef<any>(null); | |
| // Track validation state for each field | |
| const [fieldValidation, setFieldValidation] = useState({ | |
| cardNumber: false, | |
| cardExpiry: false, | |
| cardCvc: false | |
| }); | |
| // Only initialize Basis Theory when we have the API key | |
| const { bt } = useBasisTheory(basisTheorySettings?.tokenization_api_key); | |
| // Add breadcrumb and track page load when component mounts | |
| useEffect(() => { | |
| if (checkoutData) { | |
| addBreadcrumb( | |
| 'Basis Theory card form loaded', | |
| 'payment', | |
| { | |
| checkout_id: checkoutData.id, | |
| business_id: checkoutData.business_id, | |
| business_customer_id: checkoutData.business_customer_id, | |
| type: checkoutData.type | |
| } | |
| ); | |
| trackEvent('credit_card_form_loaded'); | |
| } | |
| }, [checkoutData]); | |
| // Fetch Basis Theory settings | |
| useEffect(() => { | |
| const fetchBasisTheorySettings = async () => { | |
| try { | |
| const response = await fetch( | |
| API_ENDPOINTS.BASIS_THEORY_CARD_SETTINGS + | |
| `?client_secret=${checkoutData.client_secret}` | |
| ); | |
| if (!response.ok) { | |
| throw new Error(`Failed to fetch settings: ${response.status} ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| setBasisTheorySettings(data); | |
| } catch (error) { | |
| console.error('Error fetching Basis Theory settings:', error); | |
| captureException( | |
| error instanceof Error ? error : new Error('Failed to fetch Basis Theory settings'), | |
| checkoutData?.business_id, | |
| checkoutData?.business_customer_id, | |
| 'BasisTheoryCardForm', | |
| 'basis_theory', | |
| { error_type: 'basis_theory_settings_fetch' } | |
| ); | |
| setError("Failed to load payment configuration"); | |
| } finally { | |
| setIsFormLoading(false); | |
| } | |
| }; | |
| fetchBasisTheorySettings(); | |
| }, [checkoutData.client_secret, checkoutData?.business_id, checkoutData?.business_customer_id]); | |
| // Update overall form validity when individual fields change | |
| useEffect(() => { | |
| const allFieldsValid = Object.values(fieldValidation).every(valid => valid) && | |
| firstName.trim().length > 0 && | |
| lastName.trim().length > 0 && | |
| postalCode.trim().length >= 5; | |
| setIsFormValid(allFieldsValid); | |
| }, [fieldValidation, firstName, lastName, postalCode]); | |
| // Handle card element events | |
| const handleCardNumberReady = () => { | |
| console.log("Card number element ready"); | |
| }; | |
| const handleCardNumberChange = (event: any) => { | |
| setFieldValidation(prev => ({ | |
| ...prev, | |
| cardNumber: event.complete && !event.error | |
| })); | |
| if (event.error) { | |
| console.log("Card number validation error:", event.error); | |
| } | |
| }; | |
| const handleCardExpiryChange = (event: any) => { | |
| setFieldValidation(prev => ({ | |
| ...prev, | |
| cardExpiry: event.complete && !event.error | |
| })); | |
| if (event.error) { | |
| console.log("Card expiry validation error:", event.error); | |
| } | |
| }; | |
| const handleCardCvcChange = (event: any) => { | |
| setFieldValidation(prev => ({ | |
| ...prev, | |
| cardCvc: event.complete && !event.error | |
| })); | |
| if (event.error) { | |
| console.log("Card CVC validation error:", event.error); | |
| } | |
| }; | |
| // Handle regular input field changes | |
| const handleFirstNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setFirstName(e.target.value); | |
| }; | |
| const handleLastNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setLastName(e.target.value); | |
| }; | |
| const handlePostalCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setPostalCode(e.target.value); | |
| }; | |
| const handleSaveCardChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| setSaveCard(e.target.checked); | |
| }; | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| setErrorMessage(""); | |
| if (!bt || !cardNumberRef.current || !cardExpiryRef.current || !cardCvcRef.current || !firstName || !lastName || !postalCode || !basisTheorySettings) { | |
| console.error("Basis Theory is not initialized yet or form fields are missing."); | |
| return; | |
| } | |
| // Validate form before submission | |
| if (!isFormValid) { | |
| setErrorMessage("Please complete all required fields before submitting."); | |
| return; | |
| } | |
| setIsSubmitting(true); | |
| try { | |
| console.log('Starting tokenization...'); | |
| // Tokenize card information only | |
| const tokenIntent = await bt.tokenIntents.create({ | |
| type: "card", | |
| data: { | |
| number: cardNumberRef.current, | |
| expiration_month: cardExpiryRef.current.month(), | |
| expiration_year: cardExpiryRef.current.year(), | |
| cvc: cardCvcRef.current, | |
| }, | |
| // containers: ['/general/high/'] | |
| }); | |
| // Validate that tokenization was successful | |
| if (!tokenIntent) { | |
| throw new Error('Failed to tokenize payment information - no token returned'); | |
| } | |
| // All tokenization successful - proceed with backend request | |
| const createResponse = await fetch( | |
| `${BACKEND_URL}${createPath}?client_secret=${checkoutData.client_secret}`, | |
| { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| saved: saveCard, | |
| token_intent: tokenIntent, | |
| first_name: firstName, | |
| last_name: lastName, | |
| postal_code: postalCode | |
| }), | |
| } | |
| ); | |
| if (!createResponse.ok) { | |
| const error = await createResponse.json(); | |
| captureException( | |
| new Error(error.error || 'Failed to create payment method'), | |
| checkoutData?.business_id, | |
| checkoutData?.business_customer_id, | |
| 'BasisTheoryCardForm', | |
| 'basis_theory', | |
| { error_type: 'payment_method_creation' }, | |
| { error_response: error } | |
| ); | |
| setErrorMessage(error.error || 'Failed to create payment method'); | |
| } else { | |
| const { id, last_four, payment_type, card_brand } = await createResponse.json(); | |
| onNewPaymentMethodCreated({ | |
| id, | |
| type: payment_type, | |
| last4: last_four, | |
| card_brand: card_brand, | |
| apple_pay: false // Basis Theory card form never creates Apple Pay methods | |
| }); | |
| } | |
| } catch (error: any) { | |
| console.error("Error submitting Basis Theory payment form:", error); | |
| captureException( | |
| error instanceof Error ? error : new Error('Failed to submit payment form'), | |
| checkoutData?.business_id, | |
| checkoutData?.business_customer_id, | |
| 'BasisTheoryCardForm', | |
| 'basis_theory', | |
| { error_type: 'payment_form_submission' }, | |
| { error_message: error.message } | |
| ); | |
| setErrorMessage(error.message || "Failed to process payment information. Please try again."); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| // Show loading while fetching settings | |
| if (isFormLoading) { | |
| return ( | |
| <div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-5 sm:p-8 my-4 sm:my-6 transition-all max-w-2xl mx-auto"> | |
| <div className="flex flex-col justify-center items-center h-[220px]" aria-live="polite"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-100 border-t-blue-600 mb-3"></div> | |
| <p className="font-medium text-gray-700">Loading payment configuration...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Show error if we couldn't fetch settings | |
| if (error || !basisTheorySettings) { | |
| return ( | |
| <div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-5 sm:p-8 my-4 sm:my-6 transition-all max-w-2xl mx-auto"> | |
| <div className="bg-red-50 text-red-600 p-4 rounded-xl text-sm sm:text-base border border-red-200 flex items-start" role="alert"> | |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-3 flex-shrink-0 mt-0.5 text-red-500" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> | |
| </svg> | |
| <span>{error || "Failed to load payment configuration"}</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Only render the form when we have both settings and bt instance | |
| if (!bt) { | |
| return ( | |
| <div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-5 sm:p-8 my-4 sm:my-6 transition-all max-w-2xl mx-auto"> | |
| <div className="flex flex-col justify-center items-center h-[220px]" aria-live="polite"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-100 border-t-blue-600 mb-3"></div> | |
| <p className="font-medium text-gray-700">Initializing payment form...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const elementStyle = { | |
| base: { | |
| color: '#374151', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| fontSize: '16px', | |
| fontWeight: '400', | |
| '::placeholder': { | |
| color: '#9CA3AF', | |
| }, | |
| }, | |
| invalid: { | |
| color: '#374151', | |
| }, | |
| complete: { | |
| color: '#374151', | |
| }, | |
| }; | |
| return ( | |
| <div className="bg-white rounded-2xl shadow-lg border border-gray-100 p-5 sm:p-8 my-4 sm:my-6 transition-all max-w-2xl mx-auto"> | |
| <h3 className="text-xl sm:text-2xl font-bold mb-5 text-gray-800 flex items-center"> | |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 mr-3 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /> | |
| </svg> | |
| <span className="relative"> | |
| Credit / Debit Card | |
| </span> | |
| </h3> | |
| {errorMessage && ( | |
| <div className="bg-red-50 text-red-600 p-4 rounded-xl mb-5 text-sm sm:text-base border border-red-200 flex items-start" role="alert" aria-live="assertive"> | |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-3 flex-shrink-0 mt-0.5 text-red-500" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> | |
| </svg> | |
| <span>{errorMessage}</span> | |
| </div> | |
| )} | |
| <form className="space-y-6" onSubmit={handleSubmit}> | |
| <div className="min-h-[220px] relative bg-gray-50 rounded-xl p-4 sm:p-6 border border-gray-200 shadow-inner"> | |
| {/* Name fields row */} | |
| <div className="grid grid-cols-2 gap-4 mb-4"> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| First Name * | |
| </label> | |
| <div className="bg-white border border-gray-300 rounded-lg p-3"> | |
| <input | |
| type="text" | |
| value={firstName} | |
| placeholder="First Name" | |
| onChange={handleFirstNameChange} | |
| className="w-full bg-transparent border-none outline-none" | |
| style={{ | |
| color: '#374151', | |
| fontSize: '16px', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| fontWeight: '400' | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Last Name * | |
| </label> | |
| <div className="bg-white border border-gray-300 rounded-lg p-3"> | |
| <input | |
| type="text" | |
| value={lastName} | |
| placeholder="Last Name" | |
| onChange={handleLastNameChange} | |
| className="w-full bg-transparent border-none outline-none" | |
| style={{ | |
| color: '#374151', | |
| fontSize: '16px', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| fontWeight: '400' | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Card number - full width */} | |
| <div className="mb-4"> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Card Number * | |
| </label> | |
| <div className="bg-white border border-gray-300 rounded-lg p-3"> | |
| <CardNumberElement | |
| id="card-number" | |
| bt={bt} | |
| ref={cardNumberRef} | |
| placeholder="1234 1234 1234 1234" | |
| onReady={handleCardNumberReady} | |
| onChange={handleCardNumberChange} | |
| style={elementStyle} | |
| /> | |
| </div> | |
| </div> | |
| {/* Expiration and CVV row */} | |
| <div className="grid grid-cols-2 gap-4 mb-4"> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Expiration * | |
| </label> | |
| <div className="bg-white border border-gray-300 rounded-lg p-3"> | |
| <CardExpirationDateElement | |
| id="card-expiry" | |
| bt={bt} | |
| ref={cardExpiryRef} | |
| placeholder="MM/YY" | |
| onChange={handleCardExpiryChange} | |
| style={elementStyle} | |
| /> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| CVV * | |
| </label> | |
| <div className="bg-white border border-gray-300 rounded-lg p-3"> | |
| <CardVerificationCodeElement | |
| id="card-cvc" | |
| bt={bt} | |
| ref={cardCvcRef} | |
| placeholder="123" | |
| onChange={handleCardCvcChange} | |
| style={elementStyle} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Zip code and Country row */} | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Zip Code * | |
| </label> | |
| <div className="bg-white border border-gray-300 rounded-lg p-3"> | |
| <input | |
| type="text" | |
| value={postalCode} | |
| placeholder="12345" | |
| maxLength={5} | |
| onChange={handlePostalCodeChange} | |
| className="w-full bg-transparent border-none outline-none" | |
| style={{ | |
| color: '#374151', | |
| fontSize: '16px', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| fontWeight: '400' | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Country | |
| </label> | |
| <div className="bg-white border border-gray-300 rounded-lg p-3"> | |
| <input | |
| type="text" | |
| value="US" | |
| disabled | |
| className="w-full bg-transparent border-none outline-none cursor-not-allowed" | |
| style={{ | |
| color: '#374151', | |
| fontSize: '16px', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| fontWeight: '400' | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center mt-3 bg-blue-50 p-3 rounded-lg border border-blue-100 hover:bg-blue-100 transition-colors duration-200"> | |
| <input | |
| id="save-card" | |
| type="checkbox" | |
| checked={saveCard} | |
| onChange={handleSaveCardChange} | |
| className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 cursor-pointer" | |
| /> | |
| <label htmlFor="save-card" className="ml-2 block text-xs sm:text-sm text-gray-600 font-medium cursor-pointer select-none"> | |
| Save payment method for future use | |
| </label> | |
| </div> | |
| <button | |
| type="submit" | |
| disabled={!bt || isSubmitting || !isFormValid} | |
| className={`w-full py-4 px-6 rounded-xl text-white font-semibold text-base sm:text-lg transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${ | |
| !bt || isSubmitting || !isFormValid | |
| ? 'bg-gray-400 cursor-not-allowed opacity-70' | |
| : 'bg-blue-600 hover:bg-blue-700 shadow-md hover:shadow-lg' | |
| }`} | |
| aria-live="polite" | |
| > | |
| {!bt | |
| ? 'Loading Payment Form...' | |
| : isSubmitting | |
| ? ( | |
| <span className="flex items-center justify-center"> | |
| <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> | |
| <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"></path> | |
| </svg> | |
| Processing... | |
| </span> | |
| ) | |
| : !isFormValid | |
| ? 'Please Complete Form' | |
| : 'Add Card'} | |
| </button> | |
| <div className="flex items-center justify-center space-x-4 mt-4 text-xs sm:text-sm text-gray-500"> | |
| <div className="flex items-center"> | |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> | |
| </svg> | |
| <span>Secure Transaction</span> | |
| </div> | |
| <div className="flex items-center"> | |
| <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> | |
| </svg> | |
| <span>256-bit Encryption</span> | |
| </div> | |
| </div> | |
| </form> | |
| </div> | |
| ); | |
| }; | |
| export default BasisTheoryCardForm; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment