feat: add donations feature with one-time payments - M6 (refs #55)

This commit is contained in:
Eric Gullickson
2026-01-18 16:51:20 -06:00
parent 6c1a100eb9
commit 56da99de36
14 changed files with 815 additions and 4 deletions

View File

@@ -9,4 +9,6 @@ export const subscriptionApi = {
updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data),
getInvoices: () => apiClient.get('/subscriptions/invoices'),
downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data),
createDonation: (amount: number) => apiClient.post('/donations', { amount }),
getDonations: () => apiClient.get('/donations'),
};

View File

@@ -0,0 +1,246 @@
import React, { useState } from 'react';
import {
Box,
Typography,
TextField,
Button,
CircularProgress,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
} from '@mui/material';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { format } from 'date-fns';
import toast from 'react-hot-toast';
import { Card } from '../../../shared-minimal/components/Card';
import { useCreateDonation, useDonations } from '../hooks/useSubscription';
import type { StripeCardElementChangeEvent } from '@stripe/stripe-js';
import type { SubscriptionTier } from '../types/subscription.types';
interface DonationSectionProps {
currentTier?: SubscriptionTier;
}
const CARD_ELEMENT_OPTIONS = {
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#9e2146',
},
},
};
export const DonationSection: React.FC<DonationSectionProps> = ({ 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 [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 handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
// Validate amount
const amountNum = parseFloat(amount);
if (isNaN(amountNum) || amountNum < 0.5) {
setError('Minimum donation amount is $0.50');
return;
}
setProcessing(true);
setError(null);
try {
// Create donation payment intent
const donationResponse = await createDonationMutation.mutateAsync(amountNum);
const { clientSecret } = donationResponse.data;
// Confirm payment with Stripe
const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: cardElement,
},
});
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 && !processing;
return (
<Card padding="lg">
<Typography variant="h6" fontWeight="bold" gutterBottom>
Support MotoVaultPro
</Typography>
{currentTier === 'free' && (
<Alert severity="info" sx={{ mb: 3 }}>
Love MotoVaultPro? Consider making a one-time donation to support development!
</Alert>
)}
<Typography variant="body2" color="text.secondary" gutterBottom sx={{ mb: 3 }}>
Your donations help us continue to improve and maintain MotoVaultPro. Thank you for your support!
</Typography>
{showSuccess && (
<Alert severity="success" sx={{ mb: 3 }}>
Thank you for your generous donation! Your support means the world to us.
</Alert>
)}
<form onSubmit={handleSubmit}>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Donation Amount
</Typography>
<TextField
fullWidth
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Enter amount"
InputProps={{
startAdornment: <Typography sx={{ mr: 1 }}>$</Typography>,
}}
inputProps={{
min: 0.5,
step: 0.01,
}}
disabled={processing}
/>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Card Details
</Typography>
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 2,
'&:hover': {
borderColor: 'primary.main',
},
}}
>
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
disabled={!isFormValid}
startIcon={processing && <CircularProgress size={20} />}
>
{processing ? 'Processing...' : 'Donate'}
</Button>
</form>
{donations.length > 0 && (
<Box sx={{ mt: 4 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
Donation History
</Typography>
{isLoadingDonations ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{donations.map((donation: any) => (
<TableRow key={donation.id}>
<TableCell>{format(new Date(donation.createdAt), 'MMM dd, yyyy')}</TableCell>
<TableCell>${(donation.amountCents / 100).toFixed(2)}</TableCell>
<TableCell>
<Chip
label={donation.status}
color={donation.status === 'succeeded' ? 'success' : 'default'}
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
)}
</Card>
);
};

View File

@@ -0,0 +1,224 @@
import React, { useState } from 'react';
import { CardElement, 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 } 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 [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 handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
// Validate amount
const amountNum = parseFloat(amount);
if (isNaN(amountNum) || amountNum < 0.5) {
setError('Minimum donation amount is $0.50');
return;
}
setProcessing(true);
setError(null);
try {
// Create donation payment intent
const donationResponse = await createDonationMutation.mutateAsync(amountNum);
const { clientSecret } = donationResponse.data;
// Confirm payment with Stripe
const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: cardElement,
},
});
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 && !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">
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>
);
};

View File

@@ -91,3 +91,27 @@ export const useDowngrade = () => {
},
});
};
export const useCreateDonation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (amount: number) => subscriptionApi.createDonation(amount),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['donations'] });
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } } };
toast.error(err.response?.data?.error || 'Donation failed');
},
});
};
export const useDonations = () => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: ['donations'],
queryFn: () => subscriptionApi.getDonations(),
enabled: isAuthenticated && !isLoading,
staleTime: 5 * 60 * 1000,
});
};

View File

@@ -5,6 +5,7 @@ import { format } from 'date-fns';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
import { PaymentMethodForm } from '../components/PaymentMethodForm';
import { DonationSectionMobile } from '../components/DonationSectionMobile';
import {
useSubscription,
useCheckout,
@@ -346,6 +347,10 @@ export const SubscriptionMobileScreen: React.FC = () => {
</div>
)}
</GlassCard>
<Elements stripe={stripePromise}>
<DonationSectionMobile currentTier={subscription?.tier} />
</Elements>
</div>
<MobileModal

View File

@@ -22,6 +22,7 @@ import { TierCard } from '../components/TierCard';
import { PaymentMethodForm } from '../components/PaymentMethodForm';
import { BillingHistory } from '../components/BillingHistory';
import { DowngradeFlow } from '../components/DowngradeFlow';
import { DonationSection } from '../components/DonationSection';
import {
useSubscription,
useCheckout,
@@ -250,7 +251,7 @@ export const SubscriptionPage: React.FC = () => {
</Grid>
</Card>
<Card padding="lg">
<Card padding="lg" className="mb-6">
<Typography variant="h6" fontWeight="bold" gutterBottom>
Billing History
</Typography>
@@ -264,6 +265,10 @@ export const SubscriptionPage: React.FC = () => {
)}
</Card>
<Elements stripe={stripePromise}>
<DonationSection currentTier={subscription?.tier} />
</Elements>
<Dialog
open={showPaymentDialog}
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}