Add improved production UI 4

This commit is contained in:
Urtzi Alfaro
2025-09-23 22:11:34 +02:00
parent 7892c5a739
commit 87310ced5f
17 changed files with 1658 additions and 296 deletions

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

View File

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

View File

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