feat: add vehicle selection and downgrade flow - M5 (refs #55)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import type { CheckoutRequest, PaymentMethodUpdateRequest } from '../types/subscription.types';
|
||||
import type { CheckoutRequest, PaymentMethodUpdateRequest, DowngradeRequest } from '../types/subscription.types';
|
||||
|
||||
export const subscriptionApi = {
|
||||
getSubscription: () => apiClient.get('/subscriptions'),
|
||||
@@ -8,4 +8,5 @@ export const subscriptionApi = {
|
||||
reactivate: () => apiClient.post('/subscriptions/reactivate'),
|
||||
updatePaymentMethod: (data: PaymentMethodUpdateRequest) => apiClient.put('/subscriptions/payment-method', data),
|
||||
getInvoices: () => apiClient.get('/subscriptions/invoices'),
|
||||
downgrade: (data: DowngradeRequest) => apiClient.post('/subscriptions/downgrade', data),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import { VehicleSelectionDialog } from './VehicleSelectionDialog';
|
||||
import type { SubscriptionTier } from '../types/subscription.types';
|
||||
|
||||
interface DowngradeFlowProps {
|
||||
targetTier: SubscriptionTier;
|
||||
onComplete: (vehicleIdsToKeep: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TIER_LIMITS: Record<SubscriptionTier, number | null> = {
|
||||
free: 2,
|
||||
pro: 5,
|
||||
enterprise: null, // unlimited
|
||||
};
|
||||
|
||||
export const DowngradeFlow = ({
|
||||
targetTier,
|
||||
onComplete,
|
||||
onCancel,
|
||||
}: DowngradeFlowProps) => {
|
||||
const { data: vehicles } = useVehicles();
|
||||
const [showVehicleSelection, setShowVehicleSelection] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if vehicle selection is needed
|
||||
const targetLimit = TIER_LIMITS[targetTier];
|
||||
const vehicleCount = vehicles?.length || 0;
|
||||
|
||||
if (targetLimit !== null && vehicleCount > targetLimit) {
|
||||
// Vehicle count exceeds target tier limit - show selection dialog
|
||||
setShowVehicleSelection(true);
|
||||
} else {
|
||||
// No selection needed - directly downgrade with all vehicles
|
||||
const allVehicleIds = vehicles?.map((v: any) => v.id) || [];
|
||||
onComplete(allVehicleIds);
|
||||
}
|
||||
}, [vehicles, targetTier, onComplete]);
|
||||
|
||||
const handleVehicleSelectionConfirm = (selectedVehicleIds: string[]) => {
|
||||
setShowVehicleSelection(false);
|
||||
onComplete(selectedVehicleIds);
|
||||
};
|
||||
|
||||
const handleVehicleSelectionCancel = () => {
|
||||
setShowVehicleSelection(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (!showVehicleSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetLimit = TIER_LIMITS[targetTier];
|
||||
|
||||
return (
|
||||
<VehicleSelectionDialog
|
||||
open={showVehicleSelection}
|
||||
onClose={handleVehicleSelectionCancel}
|
||||
onConfirm={handleVehicleSelectionConfirm}
|
||||
vehicles={vehicles || []}
|
||||
maxSelections={targetLimit || 0}
|
||||
targetTier={targetTier}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Alert,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import type { SubscriptionTier } from '../types/subscription.types';
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
make?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
interface VehicleSelectionDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedVehicleIds: string[]) => void;
|
||||
vehicles: Vehicle[];
|
||||
maxSelections: number;
|
||||
targetTier: SubscriptionTier;
|
||||
}
|
||||
|
||||
export const VehicleSelectionDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
vehicles,
|
||||
maxSelections,
|
||||
targetTier,
|
||||
}: VehicleSelectionDialogProps) => {
|
||||
const [selectedVehicleIds, setSelectedVehicleIds] = useState<string[]>([]);
|
||||
|
||||
// Pre-select first N vehicles when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && vehicles.length > 0) {
|
||||
const initialSelection = vehicles.slice(0, maxSelections).map((v) => v.id);
|
||||
setSelectedVehicleIds(initialSelection);
|
||||
}
|
||||
}, [open, vehicles, maxSelections]);
|
||||
|
||||
const handleToggle = (vehicleId: string) => {
|
||||
setSelectedVehicleIds((prev) => {
|
||||
if (prev.includes(vehicleId)) {
|
||||
return prev.filter((id) => id !== vehicleId);
|
||||
} else {
|
||||
// Only add if under the limit
|
||||
if (prev.length < maxSelections) {
|
||||
return [...prev, vehicleId];
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedVehicleIds);
|
||||
};
|
||||
|
||||
const getVehicleLabel = (vehicle: Vehicle): string => {
|
||||
if (vehicle.nickname) {
|
||||
return vehicle.nickname;
|
||||
}
|
||||
const parts = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean);
|
||||
return parts.join(' ') || 'Unknown Vehicle';
|
||||
};
|
||||
|
||||
const canConfirm = selectedVehicleIds.length > 0 && selectedVehicleIds.length <= maxSelections;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Select Vehicles to Keep</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
You are downgrading to the {targetTier} tier, which allows {maxSelections} vehicle
|
||||
{maxSelections > 1 ? 's' : ''}. Select which vehicles you want to keep active. Unselected
|
||||
vehicles will be hidden but not deleted, and you can unlock them by upgrading later.
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Selected {selectedVehicleIds.length} of {maxSelections} allowed
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormGroup>
|
||||
{vehicles.map((vehicle) => (
|
||||
<FormControlLabel
|
||||
key={vehicle.id}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedVehicleIds.includes(vehicle.id)}
|
||||
onChange={() => handleToggle(vehicle.id)}
|
||||
disabled={
|
||||
!selectedVehicleIds.includes(vehicle.id) &&
|
||||
selectedVehicleIds.length >= maxSelections
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={getVehicleLabel(vehicle)}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
|
||||
{selectedVehicleIds.length === 0 && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
You must select at least one vehicle.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{selectedVehicleIds.length > maxSelections && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
You can only select up to {maxSelections} vehicle{maxSelections > 1 ? 's' : ''}.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleConfirm} variant="contained" disabled={!canConfirm}>
|
||||
Confirm Downgrade
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -74,3 +74,20 @@ export const useInvoices = () => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDowngrade = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: subscriptionApi.downgrade,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
toast.success('Subscription downgraded successfully');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { message?: string } } };
|
||||
toast.error(err.response?.data?.message || 'Downgrade failed');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,12 +21,14 @@ import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { TierCard } from '../components/TierCard';
|
||||
import { PaymentMethodForm } from '../components/PaymentMethodForm';
|
||||
import { BillingHistory } from '../components/BillingHistory';
|
||||
import { DowngradeFlow } from '../components/DowngradeFlow';
|
||||
import {
|
||||
useSubscription,
|
||||
useCheckout,
|
||||
useCancelSubscription,
|
||||
useReactivateSubscription,
|
||||
useInvoices,
|
||||
useDowngrade,
|
||||
} from '../hooks/useSubscription';
|
||||
import { PLANS } from '../constants/plans';
|
||||
import type { BillingCycle, SubscriptionTier } from '../types/subscription.types';
|
||||
@@ -37,12 +39,15 @@ export const SubscriptionPage: React.FC = () => {
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
|
||||
const [selectedTier, setSelectedTier] = useState<SubscriptionTier | null>(null);
|
||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
||||
const [showDowngradeFlow, setShowDowngradeFlow] = useState(false);
|
||||
const [downgradeTargetTier, setDowngradeTargetTier] = useState<SubscriptionTier | null>(null);
|
||||
|
||||
const { data: subscriptionData, isLoading: isLoadingSubscription } = useSubscription();
|
||||
const { data: invoicesData, isLoading: isLoadingInvoices } = useInvoices();
|
||||
const checkoutMutation = useCheckout();
|
||||
const cancelMutation = useCancelSubscription();
|
||||
const reactivateMutation = useReactivateSubscription();
|
||||
const downgradeMutation = useDowngrade();
|
||||
|
||||
const subscription = subscriptionData?.data;
|
||||
const invoices = invoicesData?.data || [];
|
||||
@@ -53,9 +58,50 @@ export const SubscriptionPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getTierRank = (tier: SubscriptionTier): number => {
|
||||
const ranks = { free: 0, pro: 1, enterprise: 2 };
|
||||
return ranks[tier];
|
||||
};
|
||||
|
||||
const handleUpgradeClick = (tier: SubscriptionTier) => {
|
||||
setSelectedTier(tier);
|
||||
setShowPaymentDialog(true);
|
||||
const currentTier = subscription?.tier || 'free';
|
||||
const isDowngrade = getTierRank(tier) < getTierRank(currentTier);
|
||||
|
||||
if (isDowngrade) {
|
||||
// Trigger downgrade flow
|
||||
setDowngradeTargetTier(tier);
|
||||
setShowDowngradeFlow(true);
|
||||
} else {
|
||||
// Trigger upgrade flow (show payment dialog)
|
||||
setSelectedTier(tier);
|
||||
setShowPaymentDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDowngradeComplete = (vehicleIdsToKeep: string[]) => {
|
||||
if (!downgradeTargetTier) return;
|
||||
|
||||
downgradeMutation.mutate(
|
||||
{
|
||||
targetTier: downgradeTargetTier,
|
||||
vehicleIdsToKeep,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowDowngradeFlow(false);
|
||||
setDowngradeTargetTier(null);
|
||||
},
|
||||
onError: () => {
|
||||
setShowDowngradeFlow(false);
|
||||
setDowngradeTargetTier(null);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDowngradeCancel = () => {
|
||||
setShowDowngradeFlow(false);
|
||||
setDowngradeTargetTier(null);
|
||||
};
|
||||
|
||||
const handlePaymentSubmit = (paymentMethodId: string) => {
|
||||
@@ -244,6 +290,14 @@ export const SubscriptionPage: React.FC = () => {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{showDowngradeFlow && downgradeTargetTier && (
|
||||
<DowngradeFlow
|
||||
targetTier={downgradeTargetTier}
|
||||
onComplete={handleDowngradeComplete}
|
||||
onCancel={handleDowngradeCancel}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,3 +36,8 @@ export interface CheckoutRequest {
|
||||
export interface PaymentMethodUpdateRequest {
|
||||
paymentMethodId: string;
|
||||
}
|
||||
|
||||
export interface DowngradeRequest {
|
||||
targetTier: SubscriptionTier;
|
||||
vehicleIdsToKeep: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user