feat: add frontend subscription page - M4 (refs #55)
This commit is contained in:
@@ -59,6 +59,10 @@ const CallbackMobileScreen = lazy(() => import('./features/auth/mobile/CallbackM
|
||||
const OnboardingPage = lazy(() => import('./features/onboarding/pages/OnboardingPage').then(m => ({ default: m.OnboardingPage })));
|
||||
const OnboardingMobileScreen = lazy(() => import('./features/onboarding/mobile/OnboardingMobileScreen').then(m => ({ default: m.OnboardingMobileScreen })));
|
||||
|
||||
// Subscription pages (lazy-loaded)
|
||||
const SubscriptionPage = lazy(() => import('./features/subscription/pages/SubscriptionPage').then(m => ({ default: m.SubscriptionPage })));
|
||||
const SubscriptionMobileScreen = lazy(() => import('./features/subscription/mobile/SubscriptionMobileScreen').then(m => ({ default: m.SubscriptionMobileScreen })));
|
||||
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { BottomNavigation } from './shared-minimal/components/mobile/BottomNavigation';
|
||||
import { QuickAction } from './shared-minimal/components/mobile/quickActions';
|
||||
@@ -743,6 +747,31 @@ function App() {
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Subscription" && (
|
||||
<motion.div
|
||||
key="subscription"
|
||||
initial={{opacity:0, y:8}}
|
||||
animate={{opacity:1, y:0}}
|
||||
exit={{opacity:0, y:-8}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<MobileErrorBoundary screenName="Subscription">
|
||||
<React.Suspense fallback={
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="text-slate-500 py-6 text-center">
|
||||
Loading subscription...
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
}>
|
||||
<SubscriptionMobileScreen />
|
||||
</React.Suspense>
|
||||
</MobileErrorBoundary>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeScreen === "Documents" && (
|
||||
<motion.div
|
||||
key="documents"
|
||||
@@ -1012,6 +1041,7 @@ function App() {
|
||||
<Route path="/garage/stations" element={<StationsPage />} />
|
||||
<Route path="/garage/settings" element={<SettingsPage />} />
|
||||
<Route path="/garage/settings/security" element={<SecuritySettingsPage />} />
|
||||
<Route path="/garage/settings/subscription" element={<SubscriptionPage />} />
|
||||
<Route path="/garage/settings/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/garage/settings/admin/catalog" element={<AdminCatalogPage />} />
|
||||
<Route path="/garage/settings/admin/community-stations" element={<AdminCommunityStationsPage />} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { safeStorage } from '../utils/safe-storage';
|
||||
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs';
|
||||
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Maintenance' | 'Stations' | 'Documents' | 'Settings' | 'Security' | 'Subscription' | 'AdminUsers' | 'AdminCatalog' | 'AdminCommunityStations' | 'AdminEmailTemplates' | 'AdminBackup' | 'AdminLogs';
|
||||
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
|
||||
|
||||
interface NavigationHistory {
|
||||
|
||||
33
frontend/src/features/subscription/CLAUDE.md
Normal file
33
frontend/src/features/subscription/CLAUDE.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# frontend/src/features/subscription/
|
||||
|
||||
Subscription and billing management feature with Stripe integration.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What | When to read |
|
||||
| ---- | ---- | ------------ |
|
||||
| `README.md` | Feature overview and API integration | Understanding subscription flow |
|
||||
|
||||
## Subdirectories
|
||||
|
||||
| Directory | What | When to read |
|
||||
| --------- | ---- | ------------ |
|
||||
| `types/` | TypeScript types for subscription data | Working with subscription types |
|
||||
| `api/` | Subscription API client calls | API integration |
|
||||
| `hooks/` | React hooks for subscription data | Using subscription state |
|
||||
| `components/` | Reusable subscription UI components | Building subscription UI |
|
||||
| `pages/` | Desktop subscription page | Desktop implementation |
|
||||
| `mobile/` | Mobile subscription screen | Mobile implementation |
|
||||
| `constants/` | Subscription plan configurations | Plan pricing and features |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Desktop: MUI components with sx props
|
||||
- Mobile: Tailwind classes with GlassCard
|
||||
- Stripe Elements for payment methods
|
||||
- React Query for data fetching
|
||||
- Toast notifications for user feedback
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `VITE_STRIPE_PUBLISHABLE_KEY` - Required for Stripe Elements initialization
|
||||
111
frontend/src/features/subscription/README.md
Normal file
111
frontend/src/features/subscription/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Subscription Feature
|
||||
|
||||
Frontend UI for subscription management with Stripe integration.
|
||||
|
||||
## Overview
|
||||
|
||||
Provides subscription tier management, payment method updates, and billing history viewing.
|
||||
|
||||
## Components
|
||||
|
||||
### TierCard
|
||||
Displays subscription plan with:
|
||||
- Plan name and pricing (monthly/yearly)
|
||||
- Feature list
|
||||
- Current plan indicator
|
||||
- Upgrade/downgrade button
|
||||
|
||||
### PaymentMethodForm
|
||||
Stripe Elements integration for:
|
||||
- Credit card input with validation
|
||||
- Payment method creation
|
||||
- Error handling
|
||||
|
||||
### BillingHistory
|
||||
Invoice list with:
|
||||
- Date, amount, status
|
||||
- PDF download links
|
||||
- MUI Table component
|
||||
|
||||
## Pages
|
||||
|
||||
### SubscriptionPage (Desktop)
|
||||
- Current plan card with status
|
||||
- Three-column tier cards layout
|
||||
- Payment method section
|
||||
- Billing history table
|
||||
- Material-UI components
|
||||
|
||||
### SubscriptionMobileScreen (Mobile)
|
||||
- Stacked card layout
|
||||
- Touch-friendly buttons (44px min)
|
||||
- Tailwind styling
|
||||
- GlassCard components
|
||||
|
||||
## API Integration
|
||||
|
||||
All endpoints are in `/subscriptions`:
|
||||
- GET `/subscriptions` - Current subscription
|
||||
- POST `/subscriptions/checkout` - Upgrade subscription
|
||||
- POST `/subscriptions/cancel` - Cancel subscription
|
||||
- POST `/subscriptions/reactivate` - Reactivate subscription
|
||||
- PUT `/subscriptions/payment-method` - Update payment method
|
||||
- GET `/subscriptions/invoices` - Invoice history
|
||||
|
||||
## Hooks
|
||||
|
||||
- `useSubscription()` - Fetch current subscription
|
||||
- `useCheckout()` - Upgrade subscription
|
||||
- `useCancelSubscription()` - Cancel subscription
|
||||
- `useReactivateSubscription()` - Reactivate subscription
|
||||
- `useInvoices()` - Fetch invoice history
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Required environment variable:
|
||||
```bash
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
```
|
||||
|
||||
## Subscription Tiers
|
||||
|
||||
### Free
|
||||
- 2 vehicles
|
||||
- Basic tracking
|
||||
- Standard reports
|
||||
- Price: $0
|
||||
|
||||
### Pro
|
||||
- Up to 5 vehicles
|
||||
- VIN decoding
|
||||
- OCR functionality
|
||||
- API access
|
||||
- Price: $1.99/month or $19.99/year
|
||||
|
||||
### Enterprise
|
||||
- Unlimited vehicles
|
||||
- All Pro features
|
||||
- Priority support
|
||||
- Price: $4.99/month or $49.99/year
|
||||
|
||||
## Mobile Navigation
|
||||
|
||||
Add subscription screen to settings navigation:
|
||||
```typescript
|
||||
navigateToScreen('Subscription')
|
||||
```
|
||||
|
||||
## Desktop Routing
|
||||
|
||||
Route: `/garage/settings/subscription`
|
||||
|
||||
## Testing
|
||||
|
||||
Test subscription flow:
|
||||
1. View current plan
|
||||
2. Toggle monthly/yearly billing
|
||||
3. Select upgrade tier
|
||||
4. Enter payment method
|
||||
5. Complete checkout
|
||||
6. Verify subscription update
|
||||
7. View billing history
|
||||
11
frontend/src/features/subscription/api/subscription.api.ts
Normal file
11
frontend/src/features/subscription/api/subscription.api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import type { CheckoutRequest, PaymentMethodUpdateRequest } from '../types/subscription.types';
|
||||
|
||||
export const subscriptionApi = {
|
||||
getSubscription: () => apiClient.get('/subscriptions'),
|
||||
checkout: (data: CheckoutRequest) => apiClient.post('/subscriptions/checkout', data),
|
||||
cancel: () => apiClient.post('/subscriptions/cancel'),
|
||||
reactivate: () => apiClient.post('/subscriptions/reactivate'),
|
||||
updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data),
|
||||
getInvoices: () => apiClient.get('/subscriptions/invoices'),
|
||||
};
|
||||
100
frontend/src/features/subscription/components/BillingHistory.tsx
Normal file
100
frontend/src/features/subscription/components/BillingHistory.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: 'paid' | 'pending' | 'failed';
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
interface BillingHistoryProps {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
export const BillingHistory: React.FC<BillingHistoryProps> = ({ invoices }) => {
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No billing history available
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: Invoice['status']) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'pending':
|
||||
return 'warning';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Amount</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id} hover>
|
||||
<TableCell>
|
||||
{format(new Date(invoice.date), 'MMM dd, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
${(invoice.amount / 100).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={invoice.status}
|
||||
color={getStatusColor(invoice.status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{invoice.pdfUrl && (
|
||||
<IconButton
|
||||
size="small"
|
||||
href={invoice.pdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Download invoice PDF"
|
||||
>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Button, Typography, Alert, CircularProgress } from '@mui/material';
|
||||
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
import type { StripeCardElementChangeEvent } from '@stripe/stripe-js';
|
||||
|
||||
interface PaymentMethodFormProps {
|
||||
onSubmit: (paymentMethodId: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const CARD_ELEMENT_OPTIONS = {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#9e2146',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PaymentMethodForm: React.FC<PaymentMethodFormProps> = ({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [cardComplete, setCardComplete] = useState(false);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { error: createError, paymentMethod } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: cardElement,
|
||||
});
|
||||
|
||||
if (createError) {
|
||||
setError(createError.message || 'Failed to create payment method');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentMethod) {
|
||||
onSubmit(paymentMethod.id);
|
||||
}
|
||||
} catch {
|
||||
setError('An unexpected error occurred');
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<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={!stripe || processing || isLoading || !cardComplete}
|
||||
>
|
||||
{processing || isLoading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
'Update Payment Method'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
101
frontend/src/features/subscription/components/TierCard.tsx
Normal file
101
frontend/src/features/subscription/components/TierCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Button, Box, Chip, List, ListItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import type { SubscriptionPlan, BillingCycle } from '../types/subscription.types';
|
||||
|
||||
interface TierCardProps {
|
||||
plan: SubscriptionPlan;
|
||||
billingCycle: BillingCycle;
|
||||
currentTier?: string;
|
||||
isLoading?: boolean;
|
||||
onUpgrade: () => void;
|
||||
}
|
||||
|
||||
export const TierCard: React.FC<TierCardProps> = ({
|
||||
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 (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
border: isCurrent ? 2 : 1,
|
||||
borderColor: isCurrent ? 'primary.main' : 'divider',
|
||||
}}
|
||||
>
|
||||
{isCurrent && (
|
||||
<Chip
|
||||
label="Current Plan"
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ flexGrow: 1, pt: isCurrent ? 6 : 3 }}>
|
||||
<Typography variant="h5" component="h3" gutterBottom fontWeight="bold">
|
||||
{plan.name}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ my: 3 }}>
|
||||
<Typography variant="h3" component="div" fontWeight="bold">
|
||||
${price.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{priceLabel}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<List dense>
|
||||
{plan.features.map((feature, index) => (
|
||||
<ListItem key={index} disableGutters>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckCircleIcon color="primary" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={feature}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
{isCurrent ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
disabled
|
||||
>
|
||||
Current Plan
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={plan.tier === 'enterprise' ? 'contained' : 'outlined'}
|
||||
color="primary"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
onClick={onUpgrade}
|
||||
>
|
||||
{plan.tier === 'free' ? 'Downgrade' : 'Upgrade'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
28
frontend/src/features/subscription/constants/plans.ts
Normal file
28
frontend/src/features/subscription/constants/plans.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SubscriptionPlan } from '../types/subscription.types';
|
||||
|
||||
export const PLANS: SubscriptionPlan[] = [
|
||||
{
|
||||
tier: 'free',
|
||||
name: 'Free',
|
||||
monthlyPrice: 0,
|
||||
yearlyPrice: 0,
|
||||
vehicleLimit: 2,
|
||||
features: ['2 vehicles', 'Basic tracking', 'Standard reports'],
|
||||
},
|
||||
{
|
||||
tier: 'pro',
|
||||
name: 'Pro',
|
||||
monthlyPrice: 1.99,
|
||||
yearlyPrice: 19.99,
|
||||
vehicleLimit: 5,
|
||||
features: ['Up to 5 vehicles', 'VIN decoding', 'OCR functionality', 'API access'],
|
||||
},
|
||||
{
|
||||
tier: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
monthlyPrice: 4.99,
|
||||
yearlyPrice: 49.99,
|
||||
vehicleLimit: 'unlimited',
|
||||
features: ['Unlimited vehicles', 'All Pro features', 'Priority support'],
|
||||
},
|
||||
];
|
||||
76
frontend/src/features/subscription/hooks/useSubscription.ts
Normal file
76
frontend/src/features/subscription/hooks/useSubscription.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { subscriptionApi } from '../api/subscription.api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const useSubscription = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['subscription'],
|
||||
queryFn: () => subscriptionApi.getSubscription(),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: (failureCount, error: unknown) => {
|
||||
const err = error as { response?: { status?: number } };
|
||||
if (err?.response?.status === 401 && failureCount < 3) return true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCheckout = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: subscriptionApi.checkout,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
toast.success('Subscription upgraded successfully');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to upgrade subscription');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelSubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: subscriptionApi.cancel,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
toast.success('Subscription scheduled for cancellation');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to cancel subscription');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReactivateSubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: subscriptionApi.reactivate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
toast.success('Subscription reactivated');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } } };
|
||||
toast.error(err.response?.data?.error || 'Failed to reactivate subscription');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInvoices = () => {
|
||||
const { isAuthenticated, isLoading } = useAuth0();
|
||||
return useQuery({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => subscriptionApi.getInvoices(),
|
||||
enabled: isAuthenticated && !isLoading,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
5
frontend/src/features/subscription/index.ts
Normal file
5
frontend/src/features/subscription/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { SubscriptionPage } from './pages/SubscriptionPage';
|
||||
export { SubscriptionMobileScreen } from './mobile/SubscriptionMobileScreen';
|
||||
export { useSubscription, useCheckout, useCancelSubscription, useReactivateSubscription, useInvoices } from './hooks/useSubscription';
|
||||
export { PLANS } from './constants/plans';
|
||||
export type { Subscription, SubscriptionPlan, SubscriptionTier, BillingCycle, SubscriptionStatus } from './types/subscription.types';
|
||||
@@ -0,0 +1,365 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } 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 {
|
||||
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 || '');
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<MobileModal
|
||||
isOpen={showPaymentDialog}
|
||||
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
|
||||
title={`Upgrade to ${selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}`}
|
||||
>
|
||||
<Elements stripe={stripePromise}>
|
||||
<PaymentMethodForm
|
||||
onSubmit={handlePaymentSubmit}
|
||||
isLoading={checkoutMutation.isPending}
|
||||
/>
|
||||
</Elements>
|
||||
</MobileModal>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
249
frontend/src/features/subscription/pages/SubscriptionPage.tsx
Normal file
249
frontend/src/features/subscription/pages/SubscriptionPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { format } from 'date-fns';
|
||||
import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { TierCard } from '../components/TierCard';
|
||||
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
||||
import { BillingHistory } from '../components/BillingHistory';
|
||||
import {
|
||||
useSubscription,
|
||||
useCheckout,
|
||||
useCancelSubscription,
|
||||
useReactivateSubscription,
|
||||
useInvoices,
|
||||
} from '../hooks/useSubscription';
|
||||
import { PLANS } from '../constants/plans';
|
||||
import type { BillingCycle, SubscriptionTier } from '../types/subscription.types';
|
||||
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
|
||||
|
||||
export const SubscriptionPage: 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 handleBillingCycleChange = (_: React.MouseEvent<HTMLElement>, newCycle: BillingCycle | null) => {
|
||||
if (newCycle) {
|
||||
setBillingCycle(newCycle);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
if (isLoadingSubscription) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'past_due':
|
||||
return 'warning';
|
||||
case 'canceled':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom fontWeight="bold">
|
||||
Subscription
|
||||
</Typography>
|
||||
|
||||
{subscription && (
|
||||
<Card padding="lg" className="mb-6">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Chip
|
||||
label={subscription.tier.toUpperCase()}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={subscription.status}
|
||||
color={getStatusColor(subscription.status)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier}
|
||||
</Typography>
|
||||
|
||||
{subscription.currentPeriodEnd && (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{subscription.cancelAtPeriodEnd && (
|
||||
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||
Your subscription will be canceled at the end of the current billing period.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{subscription.cancelAtPeriodEnd ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleReactivate}
|
||||
disabled={reactivateMutation.isPending}
|
||||
>
|
||||
Reactivate
|
||||
</Button>
|
||||
) : subscription.tier !== 'free' ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
Cancel Subscription
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card padding="lg" className="mb-6">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
Available Plans
|
||||
</Typography>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={billingCycle}
|
||||
exclusive
|
||||
onChange={handleBillingCycleChange}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="monthly">Monthly</ToggleButton>
|
||||
<ToggleButton value="yearly">Yearly</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{PLANS.map((plan) => (
|
||||
<Grid item xs={12} md={4} key={plan.tier}>
|
||||
<TierCard
|
||||
plan={plan}
|
||||
billingCycle={billingCycle}
|
||||
currentTier={subscription?.tier}
|
||||
isLoading={checkoutMutation.isPending}
|
||||
onUpgrade={() => handleUpgradeClick(plan.tier)}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Typography variant="h6" fontWeight="bold" gutterBottom>
|
||||
Billing History
|
||||
</Typography>
|
||||
|
||||
{isLoadingInvoices ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<BillingHistory invoices={invoices} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={showPaymentDialog}
|
||||
onClose={() => !checkoutMutation.isPending && setShowPaymentDialog(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Upgrade to {selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Elements stripe={stripePromise}>
|
||||
<PaymentMethodForm
|
||||
onSubmit={handlePaymentSubmit}
|
||||
isLoading={checkoutMutation.isPending}
|
||||
/>
|
||||
</Elements>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => setShowPaymentDialog(false)}
|
||||
disabled={checkoutMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
export type SubscriptionTier = 'free' | 'pro' | 'enterprise';
|
||||
export type BillingCycle = 'monthly' | 'yearly';
|
||||
export type SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'unpaid';
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
status: SubscriptionStatus;
|
||||
currentPeriodStart?: string;
|
||||
currentPeriodEnd?: string;
|
||||
gracePeriodEnd?: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
tier: SubscriptionTier;
|
||||
name: string;
|
||||
monthlyPrice: number;
|
||||
yearlyPrice: number;
|
||||
features: string[];
|
||||
vehicleLimit: number | 'unlimited';
|
||||
}
|
||||
|
||||
export interface CheckoutRequest {
|
||||
tier: SubscriptionTier;
|
||||
billingCycle: BillingCycle;
|
||||
paymentMethodId: string;
|
||||
}
|
||||
|
||||
export interface PaymentMethodUpdateRequest {
|
||||
paymentMethodId: string;
|
||||
}
|
||||
Reference in New Issue
Block a user