Add subcription level filtering
This commit is contained in:
@@ -9,6 +9,7 @@ import { AppRouter } from './router/AppRouter';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { SSEProvider } from './contexts/SSEContext';
|
||||
import GlobalSubscriptionHandler from './components/auth/GlobalSubscriptionHandler';
|
||||
import './i18n';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -37,6 +38,7 @@ function App() {
|
||||
<SSEProvider>
|
||||
<Suspense fallback={<LoadingSpinner overlay />}>
|
||||
<AppRouter />
|
||||
<GlobalSubscriptionHandler />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
|
||||
@@ -18,6 +18,27 @@ export interface ApiError {
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export interface SubscriptionError {
|
||||
error: string;
|
||||
message: string;
|
||||
code: string;
|
||||
details: {
|
||||
required_feature: string;
|
||||
required_level: string;
|
||||
current_plan: string;
|
||||
upgrade_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Subscription error event emitter
|
||||
class SubscriptionErrorEmitter extends EventTarget {
|
||||
emitSubscriptionError(error: SubscriptionError) {
|
||||
this.dispatchEvent(new CustomEvent('subscriptionError', { detail: error }));
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionErrorEmitter = new SubscriptionErrorEmitter();
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
private baseURL: string;
|
||||
@@ -146,6 +167,13 @@ class ApiClient {
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
const { status, data } = error.response;
|
||||
|
||||
// Check for subscription errors
|
||||
if (status === 403 && (data as any)?.code === 'SUBSCRIPTION_UPGRADE_REQUIRED') {
|
||||
const subscriptionError = data as SubscriptionError;
|
||||
subscriptionErrorEmitter.emitSubscriptionError(subscriptionError);
|
||||
}
|
||||
|
||||
return {
|
||||
message: (data as any)?.detail || (data as any)?.message || `Request failed with status ${status}`,
|
||||
status,
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
SubscriptionLimits,
|
||||
FeatureCheckRequest,
|
||||
FeatureCheckResponse,
|
||||
UsageCheckRequest,
|
||||
UsageCheckResponse,
|
||||
UsageSummary,
|
||||
AvailablePlans,
|
||||
@@ -68,45 +66,35 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
|
||||
try {
|
||||
return await apiClient.get<UsageSummary>(`${this.baseUrl}/${tenantId}/summary`);
|
||||
} catch (error) {
|
||||
// Return mock data if backend endpoint doesn't exist yet
|
||||
console.warn('Using mock subscription data - backend endpoint not implemented yet');
|
||||
return this.getMockUsageSummary();
|
||||
}
|
||||
return apiClient.get<UsageSummary>(`${this.baseUrl}/${tenantId}/usage`);
|
||||
}
|
||||
|
||||
async getAvailablePlans(): Promise<AvailablePlans> {
|
||||
try {
|
||||
return await apiClient.get<AvailablePlans>(`${this.baseUrl}/plans`);
|
||||
} catch (error) {
|
||||
// Return mock data if backend endpoint doesn't exist yet
|
||||
console.warn('Using mock plans data - backend endpoint not implemented yet');
|
||||
return this.getMockAvailablePlans();
|
||||
}
|
||||
return apiClient.get<AvailablePlans>('/subscriptions/plans');
|
||||
}
|
||||
|
||||
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||
try {
|
||||
return await apiClient.post<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade`, {
|
||||
plan: planKey
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Using mock validation - backend endpoint not implemented yet');
|
||||
return { can_upgrade: true };
|
||||
}
|
||||
return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade/${planKey}`);
|
||||
}
|
||||
|
||||
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
|
||||
try {
|
||||
return await apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade`, {
|
||||
plan: planKey
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Using mock upgrade - backend endpoint not implemented yet');
|
||||
return { success: true, message: 'Plan actualizado correctamente (modo demo)' };
|
||||
}
|
||||
return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade?new_plan=${planKey}`, {});
|
||||
}
|
||||
|
||||
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-location`);
|
||||
}
|
||||
|
||||
async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-product`);
|
||||
}
|
||||
|
||||
async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-user`);
|
||||
}
|
||||
|
||||
async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> {
|
||||
return apiClient.get(`${this.baseUrl}/${tenantId}/features/${featureName}`);
|
||||
}
|
||||
|
||||
formatPrice(amount: number): string {
|
||||
@@ -126,69 +114,6 @@ export class SubscriptionService {
|
||||
};
|
||||
return planInfo[planKey as keyof typeof planInfo] || { name: 'Desconocido', color: 'gray' };
|
||||
}
|
||||
|
||||
private getMockUsageSummary(): UsageSummary {
|
||||
return {
|
||||
plan: 'professional',
|
||||
status: 'active',
|
||||
monthly_price: 49.99,
|
||||
next_billing_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
usage: {
|
||||
users: {
|
||||
current: 3,
|
||||
limit: 10,
|
||||
unlimited: false,
|
||||
usage_percentage: 30
|
||||
},
|
||||
locations: {
|
||||
current: 1,
|
||||
limit: 3,
|
||||
unlimited: false,
|
||||
usage_percentage: 33
|
||||
},
|
||||
products: {
|
||||
current: 45,
|
||||
limit: -1,
|
||||
unlimited: true,
|
||||
usage_percentage: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getMockAvailablePlans(): AvailablePlans {
|
||||
return {
|
||||
plans: {
|
||||
starter: {
|
||||
name: 'Starter',
|
||||
description: 'Perfecto para panaderías pequeñas',
|
||||
monthly_price: 29.99,
|
||||
max_users: 3,
|
||||
max_locations: 1,
|
||||
max_products: 50,
|
||||
popular: false
|
||||
},
|
||||
professional: {
|
||||
name: 'Professional',
|
||||
description: 'Para panaderías en crecimiento',
|
||||
monthly_price: 49.99,
|
||||
max_users: 10,
|
||||
max_locations: 3,
|
||||
max_products: -1,
|
||||
popular: true
|
||||
},
|
||||
enterprise: {
|
||||
name: 'Enterprise',
|
||||
description: 'Para grandes operaciones',
|
||||
monthly_price: 99.99,
|
||||
max_users: -1,
|
||||
max_locations: -1,
|
||||
max_products: -1,
|
||||
contact_sales: true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
60
frontend/src/components/auth/GlobalSubscriptionHandler.tsx
Normal file
60
frontend/src/components/auth/GlobalSubscriptionHandler.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* GlobalSubscriptionHandler - Listens for subscription errors and shows upgrade dialogs
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { subscriptionErrorEmitter, SubscriptionError } from '../../api/client/apiClient';
|
||||
import SubscriptionErrorHandler from './SubscriptionErrorHandler';
|
||||
|
||||
const GlobalSubscriptionHandler: React.FC = () => {
|
||||
const [subscriptionError, setSubscriptionError] = useState<SubscriptionError | null>(null);
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const handleSubscriptionError = (event: CustomEvent<SubscriptionError>) => {
|
||||
setSubscriptionError(event.detail);
|
||||
setShowErrorDialog(true);
|
||||
};
|
||||
|
||||
subscriptionErrorEmitter.addEventListener(
|
||||
'subscriptionError',
|
||||
handleSubscriptionError as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscriptionErrorEmitter.removeEventListener(
|
||||
'subscriptionError',
|
||||
handleSubscriptionError as EventListener
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowErrorDialog(false);
|
||||
setSubscriptionError(null);
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
setShowErrorDialog(false);
|
||||
setSubscriptionError(null);
|
||||
navigate('/app/settings/profile');
|
||||
};
|
||||
|
||||
if (!subscriptionError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubscriptionErrorHandler
|
||||
error={subscriptionError}
|
||||
isOpen={showErrorDialog}
|
||||
onClose={handleClose}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalSubscriptionHandler;
|
||||
export { GlobalSubscriptionHandler };
|
||||
157
frontend/src/components/auth/SubscriptionErrorHandler.tsx
Normal file
157
frontend/src/components/auth/SubscriptionErrorHandler.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* SubscriptionErrorHandler - Handles subscription-related API errors
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Button, Card } from '../ui';
|
||||
import { Crown, Lock, ArrowRight, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface SubscriptionError {
|
||||
error: string;
|
||||
message: string;
|
||||
code: string;
|
||||
details: {
|
||||
required_feature: string;
|
||||
required_level: string;
|
||||
current_plan: string;
|
||||
upgrade_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SubscriptionErrorHandlerProps {
|
||||
error: SubscriptionError;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpgrade: () => void;
|
||||
}
|
||||
|
||||
const SubscriptionErrorHandler: React.FC<SubscriptionErrorHandlerProps> = ({
|
||||
error,
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpgrade
|
||||
}) => {
|
||||
const getFeatureDisplayName = (feature: string) => {
|
||||
const featureNames: Record<string, string> = {
|
||||
analytics: 'Analytics',
|
||||
forecasting: 'Pronósticos',
|
||||
ai_insights: 'Insights de IA',
|
||||
production_optimization: 'Optimización de Producción',
|
||||
multi_location: 'Multi-ubicación'
|
||||
};
|
||||
return featureNames[feature] || feature;
|
||||
};
|
||||
|
||||
const getLevelDisplayName = (level: string) => {
|
||||
const levelNames: Record<string, string> = {
|
||||
basic: 'Básico',
|
||||
advanced: 'Avanzado',
|
||||
predictive: 'Predictivo'
|
||||
};
|
||||
return levelNames[level] || level;
|
||||
};
|
||||
|
||||
const getRequiredPlan = (level: string) => {
|
||||
switch (level) {
|
||||
case 'advanced':
|
||||
return 'Professional';
|
||||
case 'predictive':
|
||||
return 'Enterprise';
|
||||
default:
|
||||
return 'Professional';
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanColor = (plan: string) => {
|
||||
switch (plan.toLowerCase()) {
|
||||
case 'professional':
|
||||
return 'bg-gradient-to-br from-purple-500 to-indigo-600';
|
||||
case 'enterprise':
|
||||
return 'bg-gradient-to-br from-yellow-400 to-orange-500';
|
||||
default:
|
||||
return 'bg-gradient-to-br from-blue-500 to-cyan-600';
|
||||
}
|
||||
};
|
||||
|
||||
const requiredPlan = getRequiredPlan(error.details.required_level);
|
||||
const featureName = getFeatureDisplayName(error.details.required_feature);
|
||||
const levelName = getLevelDisplayName(error.details.required_level);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className={`w-16 h-16 ${getPlanColor(requiredPlan)} rounded-full flex items-center justify-center mx-auto mb-4`}>
|
||||
<Crown className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-[var(--text-primary)] mb-2">
|
||||
Actualización de Plan Requerida
|
||||
</h2>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Esta funcionalidad requiere un plan {requiredPlan} o superior
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[var(--bg-secondary)] border-0 p-4 mb-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Funcionalidad:</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">{featureName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Nivel requerido:</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">{levelName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Plan actual:</span>
|
||||
<span className="font-medium text-[var(--text-primary)] capitalize">{error.details.current_plan}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Plan requerido:</span>
|
||||
<span className="font-medium text-[var(--color-primary)]">{requiredPlan}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-800 font-medium mb-1">
|
||||
Acceso Restringido
|
||||
</p>
|
||||
<p className="text-xs text-amber-700">
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={onUpgrade}
|
||||
className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white font-medium py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
Ver Planes y Actualizar
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="w-full text-[var(--text-secondary)] py-2 px-4 rounded-lg border border-[var(--border-color)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Entendido
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-[var(--text-secondary)] flex items-center justify-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Funcionalidad protegida por suscripción
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionErrorHandler;
|
||||
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Tooltip } from '../../ui';
|
||||
@@ -136,11 +137,13 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get navigation routes from config and convert to navigation items - memoized
|
||||
// Get subscription-aware navigation routes
|
||||
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
||||
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||
|
||||
// Convert routes to navigation items - memoized
|
||||
const navigationItems = useMemo(() => {
|
||||
const navigationRoutes = getNavigationRoutes();
|
||||
|
||||
const convertRoutesToItems = (routes: typeof navigationRoutes): NavigationItem[] => {
|
||||
const convertRoutesToItems = (routes: typeof subscriptionFilteredRoutes): NavigationItem[] => {
|
||||
return routes.map(route => ({
|
||||
id: route.path,
|
||||
label: route.title,
|
||||
@@ -152,8 +155,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
}));
|
||||
};
|
||||
|
||||
return customItems || convertRoutesToItems(navigationRoutes);
|
||||
}, [customItems]);
|
||||
return customItems || convertRoutesToItems(subscriptionFilteredRoutes);
|
||||
}, [customItems, subscriptionFilteredRoutes]);
|
||||
|
||||
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||
const visibleItems = useMemo(() => {
|
||||
|
||||
179
frontend/src/hooks/useSubscription.ts
Normal file
179
frontend/src/hooks/useSubscription.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Subscription hook for checking plan features and limits
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { subscriptionService } from '../api';
|
||||
import { useCurrentTenant } from '../stores';
|
||||
import { useAuthUser } from '../stores/auth.store';
|
||||
|
||||
export interface SubscriptionFeature {
|
||||
hasFeature: boolean;
|
||||
featureLevel?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionLimits {
|
||||
canAddUser: boolean;
|
||||
canAddLocation: boolean;
|
||||
canAddProduct: boolean;
|
||||
usageData?: any;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: string;
|
||||
status: 'active' | 'inactive' | 'past_due' | 'cancelled';
|
||||
features: Record<string, any>;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const useSubscription = () => {
|
||||
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo>({
|
||||
plan: 'starter',
|
||||
status: 'active',
|
||||
features: {},
|
||||
loading: true,
|
||||
});
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
||||
|
||||
// Load subscription data
|
||||
const loadSubscriptionData = useCallback(async () => {
|
||||
if (!tenantId) {
|
||||
setSubscriptionInfo(prev => ({ ...prev, loading: false, error: 'No tenant ID available' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubscriptionInfo(prev => ({ ...prev, loading: true, error: undefined }));
|
||||
|
||||
const usageSummary = await subscriptionService.getUsageSummary(tenantId);
|
||||
|
||||
setSubscriptionInfo({
|
||||
plan: usageSummary.plan,
|
||||
status: usageSummary.status,
|
||||
features: usageSummary.usage || {},
|
||||
loading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading subscription data:', error);
|
||||
setSubscriptionInfo(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: 'Failed to load subscription data'
|
||||
}));
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSubscriptionData();
|
||||
}, [loadSubscriptionData]);
|
||||
|
||||
// Check if user has a specific feature
|
||||
const hasFeature = useCallback(async (featureName: string): Promise<SubscriptionFeature> => {
|
||||
if (!tenantId) {
|
||||
return { hasFeature: false, reason: 'No tenant ID available' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscriptionService.hasFeature(tenantId, featureName);
|
||||
return {
|
||||
hasFeature: result.has_feature,
|
||||
featureLevel: result.feature_value,
|
||||
reason: result.reason
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking feature:', error);
|
||||
return { hasFeature: false, reason: 'Error checking feature access' };
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Check analytics access level
|
||||
const getAnalyticsAccess = useCallback((): { hasAccess: boolean; level: string; reason?: string } => {
|
||||
const { plan } = subscriptionInfo;
|
||||
|
||||
switch (plan) {
|
||||
case 'starter':
|
||||
return { hasAccess: true, level: 'basic' };
|
||||
case 'professional':
|
||||
return { hasAccess: true, level: 'advanced' };
|
||||
case 'enterprise':
|
||||
return { hasAccess: true, level: 'predictive' };
|
||||
default:
|
||||
return { hasAccess: false, level: 'none', reason: 'No valid subscription plan' };
|
||||
}
|
||||
}, [subscriptionInfo.plan]);
|
||||
|
||||
// Check if user can access specific analytics features
|
||||
const canAccessAnalytics = useCallback((requiredLevel: 'basic' | 'advanced' | 'predictive' = 'basic'): boolean => {
|
||||
const { hasAccess, level } = getAnalyticsAccess();
|
||||
|
||||
if (!hasAccess) return false;
|
||||
|
||||
const levelHierarchy = {
|
||||
'basic': 1,
|
||||
'advanced': 2,
|
||||
'predictive': 3
|
||||
};
|
||||
|
||||
return levelHierarchy[level as keyof typeof levelHierarchy] >= levelHierarchy[requiredLevel];
|
||||
}, [getAnalyticsAccess]);
|
||||
|
||||
// Check if user can access forecasting features
|
||||
const canAccessForecasting = useCallback((): boolean => {
|
||||
return canAccessAnalytics('advanced'); // Forecasting requires advanced or higher
|
||||
}, [canAccessAnalytics]);
|
||||
|
||||
// Check if user can access AI insights
|
||||
const canAccessAIInsights = useCallback((): boolean => {
|
||||
return canAccessAnalytics('predictive'); // AI Insights requires enterprise plan
|
||||
}, [canAccessAnalytics]);
|
||||
|
||||
// Check usage limits
|
||||
const checkLimits = useCallback(async (): Promise<SubscriptionLimits> => {
|
||||
if (!tenantId) {
|
||||
return {
|
||||
canAddUser: false,
|
||||
canAddLocation: false,
|
||||
canAddProduct: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [userCheck, locationCheck, productCheck] = await Promise.all([
|
||||
subscriptionService.canAddUser(tenantId),
|
||||
subscriptionService.canAddLocation(tenantId),
|
||||
subscriptionService.canAddProduct(tenantId)
|
||||
]);
|
||||
|
||||
return {
|
||||
canAddUser: userCheck.can_add,
|
||||
canAddLocation: locationCheck.can_add,
|
||||
canAddProduct: productCheck.can_add,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error checking limits:', error);
|
||||
return {
|
||||
canAddUser: false,
|
||||
canAddLocation: false,
|
||||
canAddProduct: false
|
||||
};
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
return {
|
||||
subscriptionInfo,
|
||||
hasFeature,
|
||||
getAnalyticsAccess,
|
||||
canAccessAnalytics,
|
||||
canAccessForecasting,
|
||||
canAccessAIInsights,
|
||||
checkLimits,
|
||||
refreshSubscription: loadSubscriptionData,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubscription;
|
||||
69
frontend/src/hooks/useSubscriptionAwareRoutes.ts
Normal file
69
frontend/src/hooks/useSubscriptionAwareRoutes.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Hook for filtering routes based on subscription features
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { RouteConfig } from '../router/routes.config';
|
||||
import { useSubscription } from './useSubscription';
|
||||
|
||||
export const useSubscriptionAwareRoutes = (routes: RouteConfig[]) => {
|
||||
const { subscriptionInfo, canAccessAnalytics } = useSubscription();
|
||||
|
||||
const filteredRoutes = useMemo(() => {
|
||||
const filterRoutesBySubscription = (routeList: RouteConfig[]): RouteConfig[] => {
|
||||
return routeList.reduce((filtered, route) => {
|
||||
// Check if route requires subscription features
|
||||
if (route.requiredAnalyticsLevel) {
|
||||
const hasAccess = canAccessAnalytics(route.requiredAnalyticsLevel);
|
||||
if (!hasAccess) {
|
||||
return filtered; // Skip this route
|
||||
}
|
||||
}
|
||||
|
||||
// Handle specific analytics routes
|
||||
if (route.path === '/app/analytics') {
|
||||
// Only show analytics if user has at least basic access
|
||||
if (!canAccessAnalytics('basic')) {
|
||||
return filtered; // Skip analytics entirely
|
||||
}
|
||||
}
|
||||
|
||||
// Filter children recursively
|
||||
const filteredRoute = {
|
||||
...route,
|
||||
children: route.children ? filterRoutesBySubscription(route.children) : route.children
|
||||
};
|
||||
|
||||
// Only include parent if it has accessible children or is accessible itself
|
||||
if (route.children) {
|
||||
if (filteredRoute.children && filteredRoute.children.length > 0) {
|
||||
filtered.push(filteredRoute);
|
||||
} else if (!route.requiredAnalyticsLevel) {
|
||||
// Include parent without children if it doesn't require subscription
|
||||
filtered.push({ ...route, children: [] });
|
||||
}
|
||||
} else {
|
||||
filtered.push(filteredRoute);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [] as RouteConfig[]);
|
||||
};
|
||||
|
||||
if (subscriptionInfo.loading) {
|
||||
// While loading, show basic routes only
|
||||
return routes.filter(route =>
|
||||
!route.requiredAnalyticsLevel &&
|
||||
route.path !== '/app/analytics'
|
||||
);
|
||||
}
|
||||
|
||||
return filterRoutesBySubscription(routes);
|
||||
}, [routes, subscriptionInfo, canAccessAnalytics]);
|
||||
|
||||
return {
|
||||
filteredRoutes,
|
||||
subscriptionInfo,
|
||||
isLoading: subscriptionInfo.loading
|
||||
};
|
||||
};
|
||||
@@ -1209,6 +1209,67 @@ const ProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="border-t border-[var(--border-color)] pt-4 mb-6">
|
||||
<h5 className="text-sm font-semibold text-[var(--text-primary)] mb-3 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-2 text-[var(--color-primary)]" />
|
||||
Funcionalidades Incluidas
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const getPlanFeatures = (planKey: string) => {
|
||||
switch (planKey) {
|
||||
case 'starter':
|
||||
return [
|
||||
'✓ Panel de Control Básico',
|
||||
'✓ Gestión de Inventario',
|
||||
'✓ Gestión de Pedidos',
|
||||
'✓ Gestión de Proveedores',
|
||||
'✓ Punto de Venta Básico',
|
||||
'✗ Analytics Avanzados',
|
||||
'✗ Pronósticos IA',
|
||||
'✗ Insights Predictivos'
|
||||
];
|
||||
case 'professional':
|
||||
return [
|
||||
'✓ Panel de Control Avanzado',
|
||||
'✓ Gestión de Inventario Completa',
|
||||
'✓ Analytics de Ventas',
|
||||
'✓ Pronósticos con IA (92% precisión)',
|
||||
'✓ Análisis de Rendimiento',
|
||||
'✓ Optimización de Producción',
|
||||
'✓ Integración POS',
|
||||
'✗ Insights Predictivos Avanzados'
|
||||
];
|
||||
case 'enterprise':
|
||||
return [
|
||||
'✓ Todas las funcionalidades Professional',
|
||||
'✓ Insights Predictivos con IA',
|
||||
'✓ Analytics Multi-ubicación',
|
||||
'✓ Integración ERP',
|
||||
'✓ API Personalizada',
|
||||
'✓ Gestor de Cuenta Dedicado',
|
||||
'✓ Soporte 24/7 Prioritario',
|
||||
'✓ Demo Personalizada'
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return getPlanFeatures(planKey).map((feature, index) => (
|
||||
<div key={index} className={`text-xs flex items-center gap-2 ${
|
||||
feature.startsWith('✓')
|
||||
? 'text-green-600'
|
||||
: 'text-[var(--text-secondary)] opacity-60'
|
||||
}`}>
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCurrentPlan ? (
|
||||
<Badge variant="success" className="w-full justify-center py-2">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface RouteConfig {
|
||||
requiresAuth: boolean;
|
||||
requiredRoles?: string[];
|
||||
requiredPermissions?: string[];
|
||||
requiredSubscriptionFeature?: string;
|
||||
requiredAnalyticsLevel?: 'basic' | 'advanced' | 'predictive';
|
||||
showInNavigation?: boolean;
|
||||
showInBreadcrumbs?: boolean;
|
||||
children?: RouteConfig[];
|
||||
@@ -343,7 +345,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
],
|
||||
},
|
||||
|
||||
// Analytics Section
|
||||
// Analytics Section - Subscription protected
|
||||
{
|
||||
path: '/app/analytics',
|
||||
name: 'Analytics',
|
||||
@@ -352,6 +354,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
icon: 'sales',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
|
||||
requiredAnalyticsLevel: 'basic',
|
||||
showInNavigation: true,
|
||||
children: [
|
||||
{
|
||||
@@ -362,6 +365,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
icon: 'forecasting',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
|
||||
requiredAnalyticsLevel: 'advanced',
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
@@ -373,6 +377,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
icon: 'sales',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
|
||||
requiredAnalyticsLevel: 'advanced',
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
@@ -384,6 +389,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
icon: 'sales',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
|
||||
requiredAnalyticsLevel: 'advanced',
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
@@ -395,6 +401,7 @@ export const routesConfig: RouteConfig[] = [
|
||||
icon: 'forecasting',
|
||||
requiresAuth: true,
|
||||
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
|
||||
requiredAnalyticsLevel: 'predictive',
|
||||
showInNavigation: true,
|
||||
showInBreadcrumbs: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user