Merge pull request 'feat: Stripe integration with subscription tiers and donations (#55)' (#57) from issue-55-stripe-integration into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 43s
Deploy to Staging / Deploy to Staging (push) Successful in 34s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped

Reviewed-on: #57
This commit was merged in pull request #57.
This commit is contained in:
2026-01-19 03:14:38 +00:00
5 changed files with 218 additions and 40 deletions

View File

@@ -14,12 +14,12 @@ import {
TableRow, TableRow,
Chip, Chip,
} from '@mui/material'; } from '@mui/material';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { CardElement, AddressElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Card } from '../../../shared-minimal/components/Card'; import { Card } from '../../../shared-minimal/components/Card';
import { useCreateDonation, useDonations } from '../hooks/useSubscription'; import { useCreateDonation, useDonations } from '../hooks/useSubscription';
import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; import type { StripeCardElementChangeEvent, StripeAddressElementChangeEvent } from '@stripe/stripe-js';
import type { SubscriptionTier } from '../types/subscription.types'; import type { SubscriptionTier } from '../types/subscription.types';
interface DonationSectionProps { interface DonationSectionProps {
@@ -48,6 +48,7 @@ export const DonationSection: React.FC<DonationSectionProps> = ({ currentTier })
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [cardComplete, setCardComplete] = useState(false); const [cardComplete, setCardComplete] = useState(false);
const [addressComplete, setAddressComplete] = useState(false);
const [showSuccess, setShowSuccess] = useState(false); const [showSuccess, setShowSuccess] = useState(false);
const createDonationMutation = useCreateDonation(); const createDonationMutation = useCreateDonation();
@@ -60,6 +61,10 @@ export const DonationSection: React.FC<DonationSectionProps> = ({ currentTier })
setCardComplete(event.complete); setCardComplete(event.complete);
}; };
const handleAddressChange = (event: StripeAddressElementChangeEvent) => {
setAddressComplete(event.complete);
};
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -72,6 +77,11 @@ export const DonationSection: React.FC<DonationSectionProps> = ({ currentTier })
return; return;
} }
const addressElement = elements.getElement(AddressElement);
if (!addressElement) {
return;
}
// Validate amount // Validate amount
const amountNum = parseFloat(amount); const amountNum = parseFloat(amount);
if (isNaN(amountNum) || amountNum < 0.5) { if (isNaN(amountNum) || amountNum < 0.5) {
@@ -79,6 +89,13 @@ export const DonationSection: React.FC<DonationSectionProps> = ({ currentTier })
return; return;
} }
// Get billing address data
const addressData = await addressElement.getValue();
if (!addressData.complete) {
setError('Please complete the billing address');
return;
}
setProcessing(true); setProcessing(true);
setError(null); setError(null);
@@ -87,10 +104,21 @@ export const DonationSection: React.FC<DonationSectionProps> = ({ currentTier })
const donationResponse = await createDonationMutation.mutateAsync(amountNum); const donationResponse = await createDonationMutation.mutateAsync(amountNum);
const { clientSecret } = donationResponse.data; const { clientSecret } = donationResponse.data;
// Confirm payment with Stripe // Confirm payment with Stripe including billing details
const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, { const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, {
payment_method: { payment_method: {
card: cardElement, 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,
},
},
}, },
}); });
@@ -118,7 +146,7 @@ export const DonationSection: React.FC<DonationSectionProps> = ({ currentTier })
} }
}; };
const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && !processing; const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && addressComplete && !processing;
return ( return (
<Card padding="lg"> <Card padding="lg">
@@ -164,6 +192,35 @@ export const DonationSection: React.FC<DonationSectionProps> = ({ currentTier })
/> />
</Box> </Box>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Billing Address
</Typography>
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 2,
'&:hover': {
borderColor: 'primary.main',
},
}}
>
<AddressElement
options={{
mode: 'billing',
defaultValues: {
address: {
country: 'US',
},
},
}}
onChange={handleAddressChange}
/>
</Box>
</Box>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
Card Details Card Details

View File

@@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { CardElement, AddressElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { useCreateDonation, useDonations } from '../hooks/useSubscription'; import { useCreateDonation, useDonations } from '../hooks/useSubscription';
import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; import type { StripeCardElementChangeEvent, StripeAddressElementChangeEvent } from '@stripe/stripe-js';
import type { SubscriptionTier } from '../types/subscription.types'; import type { SubscriptionTier } from '../types/subscription.types';
interface DonationSectionMobileProps { interface DonationSectionMobileProps {
@@ -33,6 +33,7 @@ export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ cu
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [cardComplete, setCardComplete] = useState(false); const [cardComplete, setCardComplete] = useState(false);
const [addressComplete, setAddressComplete] = useState(false);
const [showSuccess, setShowSuccess] = useState(false); const [showSuccess, setShowSuccess] = useState(false);
const createDonationMutation = useCreateDonation(); const createDonationMutation = useCreateDonation();
@@ -45,6 +46,10 @@ export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ cu
setCardComplete(event.complete); setCardComplete(event.complete);
}; };
const handleAddressChange = (event: StripeAddressElementChangeEvent) => {
setAddressComplete(event.complete);
};
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
@@ -57,6 +62,11 @@ export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ cu
return; return;
} }
const addressElement = elements.getElement(AddressElement);
if (!addressElement) {
return;
}
// Validate amount // Validate amount
const amountNum = parseFloat(amount); const amountNum = parseFloat(amount);
if (isNaN(amountNum) || amountNum < 0.5) { if (isNaN(amountNum) || amountNum < 0.5) {
@@ -64,6 +74,13 @@ export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ cu
return; return;
} }
// Get billing address data
const addressData = await addressElement.getValue();
if (!addressData.complete) {
setError('Please complete the billing address');
return;
}
setProcessing(true); setProcessing(true);
setError(null); setError(null);
@@ -72,10 +89,21 @@ export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ cu
const donationResponse = await createDonationMutation.mutateAsync(amountNum); const donationResponse = await createDonationMutation.mutateAsync(amountNum);
const { clientSecret } = donationResponse.data; const { clientSecret } = donationResponse.data;
// Confirm payment with Stripe // Confirm payment with Stripe including billing details
const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, { const { error: confirmError } = await stripe.confirmCardPayment(clientSecret, {
payment_method: { payment_method: {
card: cardElement, 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,
},
},
}, },
}); });
@@ -103,7 +131,7 @@ export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ cu
} }
}; };
const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && !processing; const isFormValid = amount && parseFloat(amount) >= 0.5 && cardComplete && addressComplete && !processing;
return ( return (
<GlassCard> <GlassCard>
@@ -151,6 +179,25 @@ export const DonationSectionMobile: React.FC<DonationSectionMobileProps> = ({ cu
</div> </div>
</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"> <div className="mb-4">
<label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2"> <label className="block text-sm font-medium text-slate-700 dark:text-avus mb-2">
Card Details Card Details

View File

@@ -1,28 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Box, Button, Typography, Alert, CircularProgress } from '@mui/material'; import { Box, Button, Typography, Alert, CircularProgress } from '@mui/material';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { PaymentElement, AddressElement, useStripe, useElements } from '@stripe/react-stripe-js';
import type { StripeCardElementChangeEvent } from '@stripe/stripe-js'; import type { StripePaymentElementChangeEvent, StripeAddressElementChangeEvent } from '@stripe/stripe-js';
interface PaymentMethodFormProps { interface PaymentMethodFormProps {
onSubmit: (paymentMethodId: string) => void; onSubmit: (paymentMethodId: string) => void;
isLoading?: boolean; isLoading?: boolean;
} }
const CARD_ELEMENT_OPTIONS = {
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#9e2146',
},
},
};
export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({ export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({
onSubmit, onSubmit,
isLoading = false, isLoading = false,
@@ -31,11 +16,15 @@ export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({
const elements = useElements(); const elements = useElements();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [cardComplete, setCardComplete] = useState(false); const [paymentComplete, setPaymentComplete] = useState(false);
const [addressComplete, setAddressComplete] = useState(false);
const handleCardChange = (event: StripeCardElementChangeEvent) => { const handlePaymentChange = (event: StripePaymentElementChangeEvent) => {
setError(event.error?.message || null); setPaymentComplete(event.complete);
setCardComplete(event.complete); };
const handleAddressChange = (event: StripeAddressElementChangeEvent) => {
setAddressComplete(event.complete);
}; };
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
@@ -45,18 +34,49 @@ export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({
return; return;
} }
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
setProcessing(true); setProcessing(true);
setError(null); setError(null);
try { try {
// Submit the elements to validate all fields
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message || 'Failed to validate payment details');
setProcessing(false);
return;
}
// Get billing details from AddressElement
const addressElement = elements.getElement(AddressElement);
if (!addressElement) {
setError('Address element not found');
setProcessing(false);
return;
}
const addressData = await addressElement.getValue();
if (!addressData.complete) {
setError('Please complete the billing address');
setProcessing(false);
return;
}
// Create the payment method with billing details
const { error: createError, paymentMethod } = await stripe.createPaymentMethod({ const { error: createError, paymentMethod } = await stripe.createPaymentMethod({
type: 'card', elements,
card: cardElement, params: {
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 (createError) { if (createError) {
@@ -74,9 +94,39 @@ export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({
} }
}; };
const isFormComplete = paymentComplete && addressComplete;
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Box sx={{ mb: 3 }}> <Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" gutterBottom>
Billing Address
</Typography>
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 2,
mb: 3,
'&:hover': {
borderColor: 'primary.main',
},
}}
>
<AddressElement
options={{
mode: 'billing',
defaultValues: {
address: {
country: 'US',
},
},
}}
onChange={handleAddressChange}
/>
</Box>
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
Card Details Card Details
</Typography> </Typography>
@@ -91,7 +141,17 @@ export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({
}, },
}} }}
> >
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} /> <PaymentElement
options={{
layout: 'tabs',
fields: {
billingDetails: {
address: 'never',
},
},
}}
onChange={handlePaymentChange}
/>
</Box> </Box>
</Box> </Box>
@@ -106,7 +166,7 @@ export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({
variant="contained" variant="contained"
color="primary" color="primary"
fullWidth fullWidth
disabled={!stripe || processing || isLoading || !cardComplete} disabled={!stripe || processing || isLoading || !isFormComplete}
> >
{processing || isLoading ? ( {processing || isLoading ? (
<CircularProgress size={24} /> <CircularProgress size={24} />

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Elements } from '@stripe/react-stripe-js'; import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js'; import { loadStripe } from '@stripe/stripe-js';
import type { StripeElementsOptions } from '@stripe/stripe-js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer'; import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
@@ -18,6 +19,12 @@ import type { BillingCycle, SubscriptionTier, SubscriptionPlan } from '../types/
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
const paymentElementsOptions: StripeElementsOptions = {
mode: 'setup',
currency: 'usd',
paymentMethodCreation: 'manual',
};
interface MobileTierCardProps { interface MobileTierCardProps {
plan: SubscriptionPlan; plan: SubscriptionPlan;
billingCycle: BillingCycle; billingCycle: BillingCycle;
@@ -358,7 +365,7 @@ export const SubscriptionMobileScreen: React.FC = () => {
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)} onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
title={`Upgrade to ${selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}`} title={`Upgrade to ${selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}`}
> >
<Elements stripe={stripePromise}> <Elements stripe={stripePromise} options={paymentElementsOptions}>
<PaymentMethodForm <PaymentMethodForm
onSubmit={handlePaymentSubmit} onSubmit={handlePaymentSubmit}
isLoading={checkoutMutation.isPending} isLoading={checkoutMutation.isPending}

View File

@@ -16,6 +16,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { Elements } from '@stripe/react-stripe-js'; import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js'; import { loadStripe } from '@stripe/stripe-js';
import type { StripeElementsOptions } from '@stripe/stripe-js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Card } from '../../../shared-minimal/components/Card'; import { Card } from '../../../shared-minimal/components/Card';
import { TierCard } from '../components/TierCard'; import { TierCard } from '../components/TierCard';
@@ -36,6 +37,12 @@ import type { BillingCycle, SubscriptionTier } from '../types/subscription.types
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
const paymentElementsOptions: StripeElementsOptions = {
mode: 'setup',
currency: 'usd',
paymentMethodCreation: 'manual',
};
export const SubscriptionPage: React.FC = () => { export const SubscriptionPage: React.FC = () => {
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly'); const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null); const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
@@ -279,7 +286,7 @@ export const SubscriptionPage: React.FC = () => {
Upgrade to {selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name} Upgrade to {selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Elements stripe={stripePromise}> <Elements stripe={stripePromise} options={paymentElementsOptions}>
<PaymentMethodForm <PaymentMethodForm
onSubmit={handlePaymentSubmit} onSubmit={handlePaymentSubmit}
isLoading={checkoutMutation.isPending} isLoading={checkoutMutation.isPending}