feat: add donations feature with one-time payments - M6 (refs #55)
This commit is contained in:
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user