Add improved production UI 4
This commit is contained in:
179
frontend/src/api/hooks/subscription.ts
Normal file
179
frontend/src/api/hooks/subscription.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Subscription hook for checking plan features and limits
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { subscriptionService } from '../services/subscription';
|
||||
import {
|
||||
SUBSCRIPTION_PLANS,
|
||||
ANALYTICS_LEVELS,
|
||||
AnalyticsLevel,
|
||||
SubscriptionPlanKey
|
||||
} from '../types/subscription';
|
||||
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;
|
||||
|
||||
// Convert plan to typed plan key if it matches our known plans
|
||||
let planKey: keyof typeof SUBSCRIPTION_PLANS | undefined;
|
||||
if (plan === SUBSCRIPTION_PLANS.STARTER) planKey = SUBSCRIPTION_PLANS.STARTER;
|
||||
else if (plan === SUBSCRIPTION_PLANS.PROFESSIONAL) planKey = SUBSCRIPTION_PLANS.PROFESSIONAL;
|
||||
else if (plan === SUBSCRIPTION_PLANS.ENTERPRISE) planKey = SUBSCRIPTION_PLANS.ENTERPRISE;
|
||||
|
||||
if (planKey) {
|
||||
const analyticsLevel = subscriptionService.getAnalyticsLevelForPlan(planKey);
|
||||
return { hasAccess: true, level: analyticsLevel };
|
||||
}
|
||||
}, [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;
|
||||
|
||||
return subscriptionService.doesAnalyticsLevelMeetMinimum(level as any, 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;
|
||||
@@ -1,6 +1,3 @@
|
||||
/**
|
||||
* Subscription Service - Mirror backend subscription endpoints
|
||||
*/
|
||||
import { apiClient } from '../client';
|
||||
import {
|
||||
SubscriptionLimits,
|
||||
@@ -9,9 +6,27 @@ import {
|
||||
UsageSummary,
|
||||
AvailablePlans,
|
||||
PlanUpgradeValidation,
|
||||
PlanUpgradeResult
|
||||
PlanUpgradeResult,
|
||||
SUBSCRIPTION_PLANS,
|
||||
ANALYTICS_LEVELS,
|
||||
AnalyticsLevel,
|
||||
SubscriptionPlanKey,
|
||||
PLAN_HIERARCHY,
|
||||
ANALYTICS_HIERARCHY
|
||||
} from '../types/subscription';
|
||||
|
||||
// Map plan keys to analytics levels based on backend data
|
||||
const PLAN_TO_ANALYTICS_LEVEL: Record<SubscriptionPlanKey, AnalyticsLevel> = {
|
||||
[SUBSCRIPTION_PLANS.STARTER]: ANALYTICS_LEVELS.BASIC,
|
||||
[SUBSCRIPTION_PLANS.PROFESSIONAL]: ANALYTICS_LEVELS.ADVANCED,
|
||||
[SUBSCRIPTION_PLANS.ENTERPRISE]: ANALYTICS_LEVELS.PREDICTIVE
|
||||
};
|
||||
|
||||
// Cache for available plans
|
||||
let cachedPlans: AvailablePlans | null = null;
|
||||
let lastFetchTime: number | null = null;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class SubscriptionService {
|
||||
private readonly baseUrl = '/subscriptions';
|
||||
|
||||
@@ -106,13 +121,119 @@ export class SubscriptionService {
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
getPlanDisplayInfo(planKey: string): { name: string; color: string } {
|
||||
const planInfo = {
|
||||
starter: { name: 'Starter', color: 'blue' },
|
||||
professional: { name: 'Professional', color: 'purple' },
|
||||
enterprise: { name: 'Enterprise', color: 'amber' }
|
||||
};
|
||||
return planInfo[planKey as keyof typeof planInfo] || { name: 'Desconocido', color: 'gray' };
|
||||
/**
|
||||
* Fetch available subscription plans from the backend
|
||||
*/
|
||||
async fetchAvailablePlans(): Promise<AvailablePlans> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if it's still valid
|
||||
if (cachedPlans && lastFetchTime && (now - lastFetchTime) < CACHE_DURATION) {
|
||||
return cachedPlans;
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = await apiClient.get<AvailablePlans>('/subscriptions/plans');
|
||||
cachedPlans = plans;
|
||||
lastFetchTime = now;
|
||||
return plans;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscription plans:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan display information
|
||||
*/
|
||||
async getPlanDisplayInfo(planKey: string) {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
const plan = plans.plans[planKey];
|
||||
|
||||
if (plan) {
|
||||
return {
|
||||
name: plan.name,
|
||||
color: this.getPlanColor(planKey),
|
||||
description: plan.description,
|
||||
monthlyPrice: plan.monthly_price
|
||||
};
|
||||
}
|
||||
|
||||
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan display info:', error);
|
||||
return { name: 'Desconocido', color: 'gray', description: '', monthlyPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan color based on plan key
|
||||
*/
|
||||
getPlanColor(planKey: string): string {
|
||||
switch (planKey) {
|
||||
case SUBSCRIPTION_PLANS.STARTER:
|
||||
return 'blue';
|
||||
case SUBSCRIPTION_PLANS.PROFESSIONAL:
|
||||
return 'purple';
|
||||
case SUBSCRIPTION_PLANS.ENTERPRISE:
|
||||
return 'amber';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plan meets minimum requirements
|
||||
*/
|
||||
doesPlanMeetMinimum(plan: SubscriptionPlanKey, minimumRequired: SubscriptionPlanKey): boolean {
|
||||
return PLAN_HIERARCHY[plan] >= PLAN_HIERARCHY[minimumRequired];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics level for a plan
|
||||
*/
|
||||
getAnalyticsLevelForPlan(plan: SubscriptionPlanKey): AnalyticsLevel {
|
||||
return PLAN_TO_ANALYTICS_LEVEL[plan] || ANALYTICS_LEVELS.NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if analytics level meets minimum requirements
|
||||
*/
|
||||
doesAnalyticsLevelMeetMinimum(level: AnalyticsLevel, minimumRequired: AnalyticsLevel): boolean {
|
||||
return ANALYTICS_HIERARCHY[level] >= ANALYTICS_HIERARCHY[minimumRequired];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan features
|
||||
*/
|
||||
async getPlanFeatures(planKey: string) {
|
||||
try {
|
||||
const plans = await this.fetchAvailablePlans();
|
||||
const plan = plans.plans[planKey];
|
||||
|
||||
if (plan) {
|
||||
return plan.features || {};
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('Failed to get plan features:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plan has a specific feature
|
||||
*/
|
||||
async planHasFeature(planKey: string, featureName: string) {
|
||||
try {
|
||||
const features = await this.getPlanFeatures(planKey);
|
||||
return featureName in features;
|
||||
} catch (error) {
|
||||
console.error('Failed to check plan feature:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,4 +94,38 @@ export interface PlanUpgradeValidation {
|
||||
export interface PlanUpgradeResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Analytics access levels
|
||||
export const ANALYTICS_LEVELS = {
|
||||
NONE: 'none',
|
||||
BASIC: 'basic',
|
||||
ADVANCED: 'advanced',
|
||||
PREDICTIVE: 'predictive'
|
||||
} as const;
|
||||
|
||||
export type AnalyticsLevel = typeof ANALYTICS_LEVELS[keyof typeof ANALYTICS_LEVELS];
|
||||
|
||||
// Plan keys
|
||||
export const SUBSCRIPTION_PLANS = {
|
||||
STARTER: 'starter',
|
||||
PROFESSIONAL: 'professional',
|
||||
ENTERPRISE: 'enterprise'
|
||||
} as const;
|
||||
|
||||
export type SubscriptionPlanKey = typeof SUBSCRIPTION_PLANS[keyof typeof SUBSCRIPTION_PLANS];
|
||||
|
||||
// Plan hierarchy for comparison
|
||||
export const PLAN_HIERARCHY: Record<SubscriptionPlanKey, number> = {
|
||||
[SUBSCRIPTION_PLANS.STARTER]: 1,
|
||||
[SUBSCRIPTION_PLANS.PROFESSIONAL]: 2,
|
||||
[SUBSCRIPTION_PLANS.ENTERPRISE]: 3
|
||||
};
|
||||
|
||||
// Analytics level hierarchy
|
||||
export const ANALYTICS_HIERARCHY: Record<AnalyticsLevel, number> = {
|
||||
[ANALYTICS_LEVELS.NONE]: 0,
|
||||
[ANALYTICS_LEVELS.BASIC]: 1,
|
||||
[ANALYTICS_LEVELS.ADVANCED]: 2,
|
||||
[ANALYTICS_LEVELS.PREDICTIVE]: 3
|
||||
};
|
||||
Reference in New Issue
Block a user