diff --git a/frontend/.env.example b/frontend/.env.example index 2f003a0..e49213d 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 \ No newline at end of file +VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key + +# Stripe Configuration +VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index efff927..0a1da84 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index 95218a2..7cf8dd4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1e918a..4e534b5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { )} + {activeScreen === "Subscription" && ( + + + + +
+
+ Loading subscription... +
+
+
+ + }> + +
+
+
+ )} {activeScreen === "Documents" && ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/core/store/navigation.ts b/frontend/src/core/store/navigation.ts index a639870..fb507a0 100644 --- a/frontend/src/core/store/navigation.ts +++ b/frontend/src/core/store/navigation.ts @@ -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 { diff --git a/frontend/src/features/subscription/CLAUDE.md b/frontend/src/features/subscription/CLAUDE.md new file mode 100644 index 0000000..d33772f --- /dev/null +++ b/frontend/src/features/subscription/CLAUDE.md @@ -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 diff --git a/frontend/src/features/subscription/README.md b/frontend/src/features/subscription/README.md new file mode 100644 index 0000000..473ef5e --- /dev/null +++ b/frontend/src/features/subscription/README.md @@ -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 diff --git a/frontend/src/features/subscription/api/subscription.api.ts b/frontend/src/features/subscription/api/subscription.api.ts new file mode 100644 index 0000000..35c7cc4 --- /dev/null +++ b/frontend/src/features/subscription/api/subscription.api.ts @@ -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'), +}; diff --git a/frontend/src/features/subscription/components/BillingHistory.tsx b/frontend/src/features/subscription/components/BillingHistory.tsx new file mode 100644 index 0000000..080b69e --- /dev/null +++ b/frontend/src/features/subscription/components/BillingHistory.tsx @@ -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 = ({ invoices }) => { + if (!invoices || invoices.length === 0) { + return ( + + + No billing history available + + + ); + } + + const getStatusColor = (status: Invoice['status']) => { + switch (status) { + case 'paid': + return 'success'; + case 'pending': + return 'warning'; + case 'failed': + return 'error'; + default: + return 'default'; + } + }; + + return ( + + + + + Date + Amount + Status + Actions + + + + {invoices.map((invoice) => ( + + + {format(new Date(invoice.date), 'MMM dd, yyyy')} + + + ${(invoice.amount / 100).toFixed(2)} + + + + + + {invoice.pdfUrl && ( + + + + )} + + + ))} + +
+
+ ); +}; diff --git a/frontend/src/features/subscription/components/PaymentMethodForm.tsx b/frontend/src/features/subscription/components/PaymentMethodForm.tsx new file mode 100644 index 0000000..4922ef1 --- /dev/null +++ b/frontend/src/features/subscription/components/PaymentMethodForm.tsx @@ -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 = ({ + onSubmit, + isLoading = false, +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [error, setError] = useState(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 ( +
+ + + Card Details + + + + + + + {error && ( + + {error} + + )} + + +
+ ); +}; diff --git a/frontend/src/features/subscription/components/TierCard.tsx b/frontend/src/features/subscription/components/TierCard.tsx new file mode 100644 index 0000000..4e871de --- /dev/null +++ b/frontend/src/features/subscription/components/TierCard.tsx @@ -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 = ({ + 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 ( + + {isCurrent && ( + + )} + + + + {plan.name} + + + + + ${price.toFixed(2)} + + + {priceLabel} + + + + + {plan.features.map((feature, index) => ( + + + + + + + ))} + + + + + {isCurrent ? ( + + ) : ( + + )} + + + ); +}; diff --git a/frontend/src/features/subscription/constants/plans.ts b/frontend/src/features/subscription/constants/plans.ts new file mode 100644 index 0000000..1fb632a --- /dev/null +++ b/frontend/src/features/subscription/constants/plans.ts @@ -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'], + }, +]; diff --git a/frontend/src/features/subscription/hooks/useSubscription.ts b/frontend/src/features/subscription/hooks/useSubscription.ts new file mode 100644 index 0000000..943a500 --- /dev/null +++ b/frontend/src/features/subscription/hooks/useSubscription.ts @@ -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, + }); +}; diff --git a/frontend/src/features/subscription/index.ts b/frontend/src/features/subscription/index.ts new file mode 100644 index 0000000..bb481a0 --- /dev/null +++ b/frontend/src/features/subscription/index.ts @@ -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'; diff --git a/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx new file mode 100644 index 0000000..c622685 --- /dev/null +++ b/frontend/src/features/subscription/mobile/SubscriptionMobileScreen.tsx @@ -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 = ({ + 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 ( + + {isCurrent && ( +
+ + Current Plan + +
+ )} + +

+ {plan.name} +

+ +
+
+ ${price.toFixed(2)} +
+
+ {priceLabel} +
+
+ +
    + {plan.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ + {isCurrent ? ( + + ) : ( + + )} +
+ ); +}; + +interface MobileModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +const MobileModal: React.FC = ({ isOpen, onClose, title, children }) => { + if (!isOpen) return null; + + return ( +
+
+

{title}

+ {children} +
+ +
+
+
+ ); +}; + +export const SubscriptionMobileScreen: React.FC = () => { + const [billingCycle, setBillingCycle] = useState('monthly'); + const [selectedTier, setSelectedTier] = useState(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 ( + +
+
+
+
+ ); + } + + return ( + +
+

+ Subscription +

+ + {subscription && ( + +
+ + {subscription.tier.toUpperCase()} + + + {subscription.status} + +
+ +

+ Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier} +

+ + {subscription.currentPeriodEnd && ( +

+ Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')} +

+ )} + + {subscription.cancelAtPeriodEnd && ( +
+

+ Your subscription will be canceled at the end of the current billing period. +

+
+ )} + +
+ {subscription.cancelAtPeriodEnd ? ( + + ) : subscription.tier !== 'free' ? ( + + ) : null} +
+
+ )} + + +
+

+ Available Plans +

+ +
+ + +
+
+ +
+ {PLANS.map((plan) => ( + handleUpgradeClick(plan.tier)} + /> + ))} +
+
+ + +

+ Billing History +

+ + {isLoadingInvoices ? ( +
+
+
+ ) : invoices.length === 0 ? ( +

+ No billing history available +

+ ) : ( +
+ {invoices.map((invoice: { id: string; date: string; amount: number; status: string; pdfUrl?: string }) => ( +
+
+
+ {format(new Date(invoice.date), 'MMM dd, yyyy')} +
+
+ ${(invoice.amount / 100).toFixed(2)} +
+
+
+ + {invoice.status} + + {invoice.pdfUrl && ( + + ↓ + + )} +
+
+ ))} +
+ )} +
+
+ + !checkoutMutation.isPending && setShowPaymentDialog(false)} + title={`Upgrade to ${selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name}`} + > + + + + +
+ ); +}; diff --git a/frontend/src/features/subscription/pages/SubscriptionPage.tsx b/frontend/src/features/subscription/pages/SubscriptionPage.tsx new file mode 100644 index 0000000..3e6242b --- /dev/null +++ b/frontend/src/features/subscription/pages/SubscriptionPage.tsx @@ -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('monthly'); + const [selectedTier, setSelectedTier] = useState(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, 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 ( + + + + ); + } + + const getStatusColor = (status: string) => { + switch (status) { + case 'active': + return 'success'; + case 'past_due': + return 'warning'; + case 'canceled': + return 'error'; + default: + return 'default'; + } + }; + + return ( + + + Subscription + + + {subscription && ( + + + + + + + + + + Current Plan: {PLANS.find((p) => p.tier === subscription.tier)?.name || subscription.tier} + + + {subscription.currentPeriodEnd && ( + + Next billing date: {format(new Date(subscription.currentPeriodEnd), 'MMM dd, yyyy')} + + )} + + {subscription.cancelAtPeriodEnd && ( + + Your subscription will be canceled at the end of the current billing period. + + )} + + + + {subscription.cancelAtPeriodEnd ? ( + + ) : subscription.tier !== 'free' ? ( + + ) : null} + + + + )} + + + + + Available Plans + + + + Monthly + Yearly + + + + + {PLANS.map((plan) => ( + + handleUpgradeClick(plan.tier)} + /> + + ))} + + + + + + Billing History + + + {isLoadingInvoices ? ( + + + + ) : ( + + )} + + + !checkoutMutation.isPending && setShowPaymentDialog(false)} + maxWidth="sm" + fullWidth + > + + Upgrade to {selectedTier && PLANS.find((p) => p.tier === selectedTier)?.name} + + + + + + + + + + + + ); +}; diff --git a/frontend/src/features/subscription/types/subscription.types.ts b/frontend/src/features/subscription/types/subscription.types.ts new file mode 100644 index 0000000..a7d9af3 --- /dev/null +++ b/frontend/src/features/subscription/types/subscription.types.ts @@ -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; +}