feat: Accept Payments - Stripe Integration with User Tiers (#55) #56

Merged
egullickson merged 17 commits from issue-55-stripe-integration into main 2026-01-19 02:52:25 +00:00
17 changed files with 1312 additions and 5 deletions
Showing only changes of commit 94d1c677bc - Show all commits

View File

@@ -7,4 +7,7 @@ VITE_AUTH0_AUDIENCE=https://your-api-audience
VITE_API_BASE_URL=http://localhost:3001/api
# Google Maps (for future stations feature)
VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key
VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key
# Stripe Configuration
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here

View File

@@ -17,9 +17,12 @@
"@mui/material": "^6.3.0",
"@mui/x-data-grid": "^7.23.0",
"@mui/x-date-pickers": "^7.23.0",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.1",
"@tanstack/react-query": "^5.84.1",
"axios": "^1.7.9",
"clsx": "^2.0.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"framer-motion": "^12.0.0",
"react": "^19.0.0",
@@ -613,7 +616,6 @@
"node_modules/@emotion/is-prop-valid": {
"version": "1.4.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.9.0"
}
@@ -2667,6 +2669,30 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@stripe/react-stripe-js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
"integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=8.0.0 <9.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.1.tgz",
"integrity": "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.12",
"license": "MIT",
@@ -4054,6 +4080,17 @@
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"license": "MIT",
@@ -8384,7 +8421,6 @@
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -8697,7 +8733,6 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},

View File

@@ -21,9 +21,12 @@
"@mui/material": "^6.3.0",
"@mui/x-data-grid": "^7.23.0",
"@mui/x-date-pickers": "^7.23.0",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.1",
"@tanstack/react-query": "^5.84.1",
"axios": "^1.7.9",
"clsx": "^2.0.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"framer-motion": "^12.0.0",
"react": "^19.0.0",

View File

@@ -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 />} />

View File

@@ -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 {

View 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

View 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

View 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'),
};

View 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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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'],
},
];

View 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,
});
};

View 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';

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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;
}