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>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Elements } from '@stripe/react-stripe-js';
|
|
import { loadStripe } from '@stripe/stripe-js';
|
|
import type { StripeElementsOptions } from '@stripe/stripe-js';
|
|
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,
|
|
useCancelSubscription,
|
|
useReactivateSubscription,
|
|
useInvoices,
|
|
} from '../hooks/useSubscription';
|
|
import { PLANS } from '../constants/plans';
|
|
import type { BillingCycle, SubscriptionTier, SubscriptionPlan } from '../types/subscription.types';
|
|
|
|
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
|
|
|
|
const paymentElementsOptions: StripeElementsOptions = {
|
|
mode: 'setup',
|
|
currency: 'usd',
|
|
paymentMethodCreation: 'manual',
|
|
};
|
|
|
|
interface MobileTierCardProps {
|
|
plan: SubscriptionPlan;
|
|
billingCycle: BillingCycle;
|
|
currentTier?: string;
|
|
isLoading?: boolean;
|
|
onUpgrade: () => void;
|
|
}
|
|
|
|
const MobileTierCard: React.FC<MobileTierCardProps> = ({
|
|
plan,
|
|
billingCycle,
|
|
currentTier,
|
|
isLoading = false,
|
|
onUpgrade,
|
|
}) => {
|
|
const isCurrent = currentTier === plan.tier;
|
|
const price = billingCycle === 'monthly' ? plan.monthlyPrice : plan.yearlyPrice;
|
|
const priceLabel = billingCycle === 'monthly' ? '/month' : '/year';
|
|
|
|
return (
|
|
<GlassCard className={`${isCurrent ? 'border-2 border-rose-500' : ''}`}>
|
|
{isCurrent && (
|
|
<div className="mb-3">
|
|
<span className="inline-block bg-rose-500 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
|
Current Plan
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<h3 className="text-xl font-bold text-slate-800 dark:text-avus mb-2">
|
|
{plan.name}
|
|
</h3>
|
|
|
|
<div className="my-4">
|
|
<div className="text-3xl font-bold text-slate-900 dark:text-white">
|
|
${price.toFixed(2)}
|
|
</div>
|
|
<div className="text-sm text-slate-600 dark:text-titanio">
|
|
{priceLabel}
|
|
</div>
|
|
</div>
|
|
|
|
<ul className="space-y-2 mb-4">
|
|
{plan.features.map((feature, index) => (
|
|
<li key={index} className="flex items-start gap-2 text-sm">
|
|
<span className="text-rose-500 mt-0.5">✓</span>
|
|
<span className="text-slate-700 dark:text-avus">{feature}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{isCurrent ? (
|
|
<button
|
|
disabled
|
|
className="w-full py-3 px-4 bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-xl font-semibold"
|
|
>
|
|
Current Plan
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={onUpgrade}
|
|
disabled={isLoading}
|
|
className={`w-full py-3 px-4 rounded-xl font-semibold min-h-[44px] ${
|
|
plan.tier === 'enterprise'
|
|
? 'bg-rose-500 text-white hover:bg-rose-600 disabled:bg-gray-300'
|
|
: 'border-2 border-rose-500 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900/20 disabled:border-gray-300 disabled:text-gray-400'
|
|
}`}
|
|
>
|
|
{plan.tier === 'free' ? 'Downgrade' : 'Upgrade'}
|
|
</button>
|
|
)}
|
|
</GlassCard>
|
|
);
|
|
};
|
|
|
|
interface MobileModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const MobileModal: React.FC<MobileModalProps> = ({ isOpen, onClose, title, children }) => {
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 dark:bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-scuro rounded-3xl p-6 max-w-md w-full">
|
|
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
|
|
{children}
|
|
<div className="flex justify-end mt-4">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-6 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl font-medium min-h-[44px]"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SubscriptionMobileScreen: React.FC = () => {
|
|
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
|
|
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
|
|
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
|
|
|
const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscription();
|
|
const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices();
|
|
const checkoutMutation = useCheckout();
|
|
const cancelMutation = useCancelSubscription();
|
|
const reactivateMutation = useReactivateSubscription();
|
|
|
|
const subscription = subscriptionData?.data;
|
|
const invoices = invoicesData?.data || [];
|
|
|
|
const handleUpgradeClick = (tier: SubscriptionTier) => {
|
|
setSelectedTier(tier);
|
|
setShowPaymentDialog(true);
|
|
};
|
|
|
|
const handlePaymentSubmit = (paymentMethodId: string) => {
|
|
if (!selectedTier) return;
|
|
|
|
checkoutMutation.mutate(
|
|
{
|
|
tier: selectedTier,
|
|
billingCycle,
|
|
paymentMethodId,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
setShowPaymentDialog(false);
|
|
setSelectedTier(null);
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (window.confirm('Are you sure you want to cancel your subscription? Your plan will remain active until the end of the current billing period.')) {
|
|
cancelMutation.mutate();
|
|
}
|
|
};
|
|
|
|
const handleReactivate = () => {
|
|
reactivateMutation.mutate();
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'active':
|
|
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
|
case 'past_due':
|
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
|
case 'canceled':
|
|
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
|
}
|
|
};
|
|
|
|
if (isLoadingSubscription) {
|
|
return (
|
|
<MobileContainer>
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-rose-500"></div>
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<MobileContainer>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">
|
|
Subscription
|
|
</h1>
|
|
|
|
{subscription && (
|
|
<GlassCard>
|
|
<div className="flex gap-2 mb-3">
|
|
<span className="inline-block bg-rose-500 text-white text-xs font-semibold px-3 py-1 rounded-full">
|
|
{subscription.tier.toUpperCase()}
|
|
</span>
|
|
<span className={`inline-block text-xs font-semibold px-3 py-1 rounded-full ${getStatusColor(subscription.status)}`}>
|
|
{subscription.status}
|
|
</span>
|
|
</div>
|
|
|
|
<h2 className="text-xl font-bold text-slate-800 dark:text-avus mb-2">
|
|
Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier}
|
|
</h2>
|
|
|
|
{subscription.currentPeriodEnd && (
|
|
<p className="text-sm text-slate-600 dark:text-titanio mb-3">
|
|
Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')}
|
|
</p>
|
|
)}
|
|
|
|
{subscription.cancelAtPeriodEnd && (
|
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-xl p-3 mb-3">
|
|
<p className="text-sm text-yellow-800 dark:text-yellow-300">
|
|
Your subscription will be canceled at the end of the current billing period.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4">
|
|
{subscription.cancelAtPeriodEnd ? (
|
|
<button
|
|
onClick={handleReactivate}
|
|
disabled={reactivateMutation.isPending}
|
|
className="w-full py-3 px-4 border-2 border-rose-500 text-rose-500 rounded-xl font-semibold min-h-[44px] hover:bg-rose-50 dark:hover:bg-rose-900/20 disabled:opacity-50"
|
|
>
|
|
Reactivate
|
|
</button>
|
|
) : subscription.tier !== 'free' ? (
|
|
<button
|
|
onClick={handleCancel}
|
|
disabled={cancelMutation.isPending}
|
|
className="w-full py-3 px-4 border-2 border-red-500 text-red-500 rounded-xl font-semibold min-h-[44px] hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50"
|
|
>
|
|
Cancel Subscription
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</GlassCard>
|
|
)}
|
|
|
|
<GlassCard>
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-xl font-bold text-slate-800 dark:text-avus">
|
|
Available Plans
|
|
</h2>
|
|
|
|
<div className="flex bg-slate-100 dark:bg-scuro rounded-lg p-1">
|
|
<button
|
|
onClick={() => setBillingCycle('monthly')}
|
|
className={`px-3 py-1 text-sm font-medium rounded min-h-[36px] ${
|
|
billingCycle === 'monthly'
|
|
? 'bg-white dark:bg-nero text-rose-500 shadow-sm'
|
|
: 'text-slate-600 dark:text-titanio'
|
|
}`}
|
|
>
|
|
Monthly
|
|
</button>
|
|
<button
|
|
onClick={() => setBillingCycle('yearly')}
|
|
className={`px-3 py-1 text-sm font-medium rounded min-h-[36px] ${
|
|
billingCycle === 'yearly'
|
|
? 'bg-white dark:bg-nero text-rose-500 shadow-sm'
|
|
: 'text-slate-600 dark:text-titanio'
|
|
}`}
|
|
>
|
|
Yearly
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{PLANS.map((plan) => (
|
|
<MobileTierCard
|
|
key={plan.tier}
|
|
plan={plan}
|
|
billingCycle={billingCycle}
|
|
currentTier={subscription?.tier}
|
|
isLoading={checkoutMutation.isPending}
|
|
onUpgrade={() => handleUpgradeClick(plan.tier)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</GlassCard>
|
|
|
|
<GlassCard>
|
|
<h2 className="text-xl font-bold text-slate-800 dark:text-avus mb-4">
|
|
Billing History
|
|
</h2>
|
|
|
|
{isLoadingInvoices ? (
|
|
<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>
|
|
) : invoices.length === 0 ? (
|
|
<p className="text-center text-sm text-slate-600 dark:text-titanio p-6">
|
|
No billing history available
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{invoices.map((invoice: { id: string; date: string; amount: number; status: string; pdfUrl?: string }) => (
|
|
<div
|
|
key={invoice.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(invoice.date), 'MMM dd, yyyy')}
|
|
</div>
|
|
<div className="text-xs text-slate-600 dark:text-titanio">
|
|
${(invoice.amount / 100).toFixed(2)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
|
invoice.status === 'paid'
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
|
: invoice.status === 'pending'
|
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
|
}`}>
|
|
{invoice.status}
|
|
</span>
|
|
{invoice.pdfUrl && (
|
|
<a
|
|
href={invoice.pdfUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-rose-500 text-sm min-h-[44px] min-w-[44px] flex items-center justify-center"
|
|
>
|
|
↓
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</GlassCard>
|
|
|
|
<Elements stripe={stripePromise}>
|
|
<DonationSectionMobile currentTier={subscription?.tier} />
|
|
</Elements>
|
|
</div>
|
|
|
|
<MobileModal
|
|
isOpen={showPaymentDialog}
|
|
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
|
|
title={`Upgrade to ${selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}`}
|
|
>
|
|
<Elements stripe={stripePromise} options={paymentElementsOptions}>
|
|
<PaymentMethodForm
|
|
onSubmit={handlePaymentSubmit}
|
|
isLoading={checkoutMutation.isPending}
|
|
/>
|
|
</Elements>
|
|
</MobileModal>
|
|
</MobileContainer>
|
|
);
|
|
};
|