All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m4s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 38s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- Replace CardElement with PaymentElement + AddressElement in subscription forms - Add AddressElement to donation forms for billing address collection - Now collects: Name, Address Line 1/2, City, State, Postal Code, Country - Card details: Card Number, Expiration, CVC - Both desktop and mobile forms updated 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
272 lines
9.3 KiB
TypeScript
272 lines
9.3 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { CardElement, AddressElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
|
import { format } from 'date-fns';
|
|
import toast from 'react-hot-toast';
|
|
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
|
import { useCreateDonation, useDonations } from '../hooks/useSubscription';
|
|
import type { StripeCardElementChangeEvent, StripeAddressElementChangeEvent } from '@stripe/stripe-js';
|
|
import type { SubscriptionTier } from '../types/subscription.types';
|
|
|
|
interface DonationSectionMobileProps {
|
|
currentTier?: SubscriptionTier;
|
|
}
|
|
|
|
const CARD_ELEMENT_OPTIONS = {
|
|
style: {
|
|
base: {
|
|
fontSize: '16px',
|
|
color: '#424770',
|
|
'::placeholder': {
|
|
color: '#aab7c4',
|
|
},
|
|
},
|
|
invalid: {
|
|
color: '#9e2146',
|
|
},
|
|
},
|
|
};
|
|
|
|
export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ currentTier }) => {
|
|
const stripe = useStripe();
|
|
const elements = useElements();
|
|
const [amount, setAmount] = useState<string>('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [processing, setProcessing] = useState(false);
|
|
const [cardComplete, setCardComplete] = useState(false);
|
|
const [addressComplete, setAddressComplete] = useState(false);
|
|
const [showSuccess, setShowSuccess] = useState(false);
|
|
|
|
const createDonationMutation = useCreateDonation();
|
|
const { data: donationsData, isLoading: isLoadingDonations } = useDonations();
|
|
|
|
const donations = donationsData?.data || [];
|
|
|
|
const handleCardChange = (event: StripeCardElementChangeEvent) => {
|
|
setError(event.error?.message || null);
|
|
setCardComplete(event.complete);
|
|
};
|
|
|
|
const handleAddressChange = (event: StripeAddressElementChangeEvent) => {
|
|
setAddressComplete(event.complete);
|
|
};
|
|
|
|
const handleSubmit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
|
|
if (!stripe || !elements) {
|
|
return;
|
|
}
|
|
|
|
const cardElement = elements.getElement(CardElement);
|
|
if (!cardElement) {
|
|
return;
|
|
}
|
|
|
|
const addressElement = elements.getElement(AddressElement);
|
|
if (!addressElement) {
|
|
return;
|
|
}
|
|
|
|
// Validate amount
|
|
const amountNum = parseFloat(amount);
|
|
if (isNaN(amountNum) || amountNum < 0.5) {
|
|
setError('Minimum donation amount is $0.50');
|
|
return;
|
|
}
|
|
|
|
// Get billing address data
|
|
const addressData = await addressElement.getValue();
|
|
if (!addressData.complete) {
|
|
setError('Please complete the billing address');
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Create donation payment intent
|
|
const donationResponse = await createDonationMutation.mutateAsync(amountNum);
|
|
const { clientSecret } = donationResponse.data;
|
|
|
|
// Confirm payment with Stripe including billing details
|
|
const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, {
|
|
payment_method: {
|
|
card: cardElement,
|
|
billing_details: {
|
|
name: addressData.value.name,
|
|
address: {
|
|
line1: addressData.value.address.line1,
|
|
line2: addressData.value.address.line2 || undefined,
|
|
city: addressData.value.address.city,
|
|
state: addressData.value.address.state,
|
|
postal_code: addressData.value.address.postal_code,
|
|
country: addressData.value.address.country,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (confirmError) {
|
|
setError(confirmError.message || 'Payment failed');
|
|
setProcessing(false);
|
|
return;
|
|
}
|
|
|
|
// Success!
|
|
setShowSuccess(true);
|
|
setAmount('');
|
|
cardElement.clear();
|
|
toast.success('Thank you for your donation!');
|
|
|
|
// Hide success message after 5 seconds
|
|
setTimeout(() => {
|
|
setShowSuccess(false);
|
|
}, 5000);
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { error?: string } } };
|
|
setError(error.response?.data?.error || 'An unexpected error occurred');
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && addressComplete && !processing;
|
|
|
|
return (
|
|
<GlassCard>
|
|
<h2 className="text-xl font-bold text-slate-800 dark:text-avus mb-3">
|
|
Support MotoVaultPro
|
|
</h2>
|
|
|
|
{currentTier === 'free' && (
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl p-3 mb-4">
|
|
<p className="text-sm text-blue-800 dark:text-blue-300">
|
|
Love MotoVaultPro? Consider making a one-time donation to support development!
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-sm text-slate-600 dark:text-titanio mb-4">
|
|
Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support!
|
|
</p>
|
|
|
|
{showSuccess && (
|
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-xl p-3 mb-4">
|
|
<p className="text-sm text-green-800 dark:text-green-300">
|
|
Thank you for your generous donation! Your support means the world to us.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
|
|
Donation Amount
|
|
</label>
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-3 text-slate-600 dark:text-titanio">$</span>
|
|
<input
|
|
type="number"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
placeholder="Enter amount"
|
|
min="0.5"
|
|
step="0.01"
|
|
disabled={processing}
|
|
className="w-full pl-8 pr-4 py-3 bg-white dark:bg-nero border border-slate-200 dark:border-grigio rounded-xl text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-titanio focus:outline-none focus:ring-2 focus:ring-rose-500 min-h-[44px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
|
|
Billing Address
|
|
</label>
|
|
<div className="border border-slate-200 dark:border-grigio rounded-xl p-3 bg-white dark:bg-nero">
|
|
<AddressElement
|
|
options={{
|
|
mode: 'billing',
|
|
defaultValues: {
|
|
address: {
|
|
country: 'US',
|
|
},
|
|
},
|
|
}}
|
|
onChange={handleAddressChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
|
|
Card Details
|
|
</label>
|
|
<div className="border border-slate-200 dark:border-grigio rounded-xl p-3 bg-white dark:bg-nero">
|
|
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-xl p-3 mb-4">
|
|
<p className="text-sm text-red-800 dark:text-red-300">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={!isFormValid}
|
|
className={`w-full py-3 px-4 rounded-xl font-semibold min-h-[44px] ${
|
|
isFormValid
|
|
? 'bg-rose-500 text-white hover:bg-rose-600'
|
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{processing ? 'Processing...' : 'Donate'}
|
|
</button>
|
|
</form>
|
|
|
|
{donations.length > 0 && (
|
|
<div className="mt-6">
|
|
<h3 className="text-lg font-bold text-slate-800 dark:text-avus mb-3">
|
|
Donation History
|
|
</h3>
|
|
|
|
{isLoadingDonations ? (
|
|
<div className="flex justify-center p-6">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-rose-500"></div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{donations.map((donation: any) => (
|
|
<div
|
|
key={donation.id}
|
|
className="flex justify-between items-center p-3 bg-slate-50 dark:bg-scuro rounded-xl"
|
|
>
|
|
<div>
|
|
<div className="text-sm font-medium text-slate-800 dark:text-avus">
|
|
{format(new Date(donation.createdAt), 'MMM dd, yyyy')}
|
|
</div>
|
|
<div className="text-xs text-slate-600 dark:text-titanio">
|
|
${(donation.amountCents / 100).toFixed(2)}
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded-full ${
|
|
donation.status === 'succeeded'
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
|
|
}`}
|
|
>
|
|
{donation.status}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</GlassCard>
|
|
);
|
|
};
|