Improve subcription support

This commit is contained in:
Urtzi Alfaro
2025-09-01 19:21:12 +02:00
parent 72b4f60cf5
commit 6346c4bcb9
18 changed files with 3175 additions and 114 deletions

View File

@@ -20,6 +20,7 @@ import {
Bell,
Settings,
User,
CreditCard,
ChevronLeft,
ChevronRight,
ChevronDown,
@@ -95,6 +96,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
notifications: Bell,
settings: Settings,
user: User,
'credit-card': CreditCard,
};
/**

View File

@@ -20,6 +20,14 @@ export const MOCK_CONFIG = {
tenant_name: 'Panadería Artesanal El Buen Pan'
},
// Mock admin user data for testing
MOCK_ADMIN_USER: {
full_name: 'Admin User',
email: 'admin@bakery.com',
role: 'admin',
tenant_name: 'Bakery Admin Demo'
},
// Mock bakery data
MOCK_BAKERY: {
name: 'Panadería Artesanal El Buen Pan',
@@ -27,6 +35,69 @@ export const MOCK_CONFIG = {
location: 'Av. Principal 123, Centro Histórico',
phone: '+1 234 567 8900',
email: 'info@elbuenpan.com'
},
// Mock subscription data for admin@bakery.com
MOCK_SUBSCRIPTION: {
id: 'sub_admin_demo_001',
tenant_id: 'tenant_admin_demo',
plan: 'professional',
status: 'active',
monthly_price: 129.0,
currency: 'EUR',
billing_cycle: 'monthly',
current_period_start: '2024-08-01T00:00:00Z',
current_period_end: '2024-09-01T00:00:00Z',
next_billing_date: '2024-09-01T00:00:00Z',
trial_ends_at: null,
canceled_at: null,
created_at: '2024-01-15T10:30:00Z',
updated_at: '2024-08-01T00:00:00Z',
max_users: 15,
max_locations: 2,
max_products: -1,
features: {
inventory_management: 'advanced',
demand_prediction: 'ai_92_percent',
production_management: 'complete',
pos_integrated: true,
logistics: 'basic',
analytics: 'advanced',
support: 'priority_24_7',
trial_days: 14,
locations: '1_2_locations'
},
usage: {
users: 8,
locations: 1,
products: 145,
storage_gb: 2.4,
api_calls_month: 1250,
reports_generated: 23
},
billing_history: [
{
id: 'inv_001',
date: '2024-08-01T00:00:00Z',
amount: 129.0,
status: 'paid' as 'paid',
description: 'Plan Professional - Agosto 2024'
},
{
id: 'inv_002',
date: '2024-07-01T00:00:00Z',
amount: 129.0,
status: 'paid' as 'paid',
description: 'Plan Professional - Julio 2024'
},
{
id: 'inv_003',
date: '2024-06-01T00:00:00Z',
amount: 129.0,
status: 'paid' as 'paid',
description: 'Plan Professional - Junio 2024'
}
]
}
};
@@ -34,4 +105,8 @@ export const MOCK_CONFIG = {
export const isMockMode = () => MOCK_CONFIG.MOCK_MODE;
export const isMockRegistration = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_REGISTRATION;
export const isMockAuthentication = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_AUTHENTICATION;
export const isMockOnboardingFlow = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_ONBOARDING_FLOW;
export const isMockOnboardingFlow = () => MOCK_CONFIG.MOCK_MODE && MOCK_CONFIG.MOCK_ONBOARDING_FLOW;
// Helper functions to get mock data
export const getMockUser = (isAdmin = false) => isAdmin ? MOCK_CONFIG.MOCK_ADMIN_USER : MOCK_CONFIG.MOCK_USER;
export const getMockSubscription = () => MOCK_CONFIG.MOCK_SUBSCRIPTION;

View File

@@ -0,0 +1,7 @@
// Settings pages
export { default as ProfilePage } from './profile';
export { default as BakeryConfigPage } from './bakery-config';
export { default as TeamPage } from './team';
export { default as SystemSettingsPage } from './system';
export { default as TrainingPage } from './training';
export { default as SubscriptionPage } from './subscription';

View File

@@ -0,0 +1,855 @@
/**
* Subscription Management Page
* Allows users to view current subscription, billing details, and upgrade plans
*/
import React, { useState, useEffect } from 'react';
import {
Card,
Button,
Badge,
Modal
} from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import {
CreditCard,
Users,
MapPin,
Package,
TrendingUp,
Calendar,
CheckCircle,
AlertCircle,
ArrowRight,
Crown,
Star,
Zap,
X,
RefreshCw,
Settings,
Download,
ExternalLink
} from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth';
import { useBakeryStore } from '../../../../stores/bakery.store';
import { useToast } from '../../../../hooks/ui/useToast';
import {
subscriptionService,
type UsageSummary,
type AvailablePlans
} from '../../../../services/api';
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
interface PlanComparisonProps {
plans: AvailablePlans['plans'];
currentPlan: string;
onUpgrade: (planKey: string) => void;
}
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
const getProgressColor = () => {
if (value >= 90) return 'bg-red-500';
if (value >= 80) return 'bg-yellow-500';
return 'bg-green-500';
};
return (
<div className={`w-full bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] rounded-full h-3 ${className}`}>
<div
className={`${getProgressColor()} h-full rounded-full transition-all duration-500 relative`}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-white/20 rounded-full"></div>
</div>
</div>
);
};
// Tabs implementation
interface TabsProps {
defaultValue: string;
className?: string;
children: React.ReactNode;
}
interface TabsListProps {
className?: string;
children: React.ReactNode;
}
interface TabsTriggerProps {
value: string;
children: React.ReactNode;
className?: string;
}
interface TabsContentProps {
value: string;
children: React.ReactNode;
className?: string;
}
const TabsContext = React.createContext<{ activeTab: string; setActiveTab: (value: string) => void } | null>(null);
const Tabs: React.FC<TabsProps> & {
List: React.FC<TabsListProps>;
Trigger: React.FC<TabsTriggerProps>;
Content: React.FC<TabsContentProps>;
} = ({
defaultValue,
className = '',
children
}) => {
const [activeTab, setActiveTab] = useState(defaultValue);
return (
<div className={className}>
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
</div>
);
};
const TabsList: React.FC<TabsListProps> = ({ className = '', children }) => {
return (
<div className={`flex border-b border-[var(--border-primary)] bg-[var(--bg-primary)] ${className}`}>
{children}
</div>
);
};
const TabsTrigger: React.FC<TabsTriggerProps> = ({ value, children, className = '' }) => {
const context = React.useContext(TabsContext);
if (!context) throw new Error('TabsTrigger must be used within Tabs');
const { activeTab, setActiveTab } = context;
const isActive = activeTab === value;
return (
<button
onClick={() => setActiveTab(value)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-all duration-200 relative flex items-center ${
isActive
? 'text-[var(--color-primary)] border-[var(--color-primary)] bg-[var(--bg-primary)]'
: 'text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)] hover:border-[var(--color-primary)]/30 hover:bg-[var(--bg-secondary)]'
} ${className}`}
>
{children}
</button>
);
};
const TabsContent: React.FC<TabsContentProps> = ({ value, children, className = '' }) => {
const context = React.useContext(TabsContext);
if (!context) throw new Error('TabsContent must be used within Tabs');
const { activeTab } = context;
if (activeTab !== value) return null;
return (
<div className={`bg-[var(--bg-primary)] rounded-b-lg p-6 ${className}`}>
{children}
</div>
);
};
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Content = TabsContent;
const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onUpgrade }) => {
const planOrder = ['starter', 'professional', 'enterprise'];
const sortedPlans = Object.entries(plans).sort(([a], [b]) =>
planOrder.indexOf(a) - planOrder.indexOf(b)
);
const getPlanColor = (planKey: string) => {
switch (planKey) {
case 'starter': return 'border-blue-500/30 bg-blue-500/5';
case 'professional': return 'border-purple-500/30 bg-purple-500/5';
case 'enterprise': return 'border-amber-500/30 bg-amber-500/5';
default: return 'border-[var(--border-primary)] bg-[var(--bg-secondary)]';
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{sortedPlans.map(([planKey, plan]) => (
<Card
key={planKey}
className={`relative p-6 ${getPlanColor(planKey)} ${
currentPlan === planKey ? 'ring-2 ring-[var(--color-primary)]' : ''
}`}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
<Badge variant="primary" className="px-3 py-1">
<Star className="w-3 h-3 mr-1" />
Más Popular
</Badge>
</div>
)}
<div className="text-center mb-6">
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-2">{plan.name}</h3>
<div className="text-3xl font-bold text-[var(--color-primary)] mb-1">
{subscriptionService.formatPrice(plan.monthly_price)}
<span className="text-lg text-[var(--text-secondary)]">/mes</span>
</div>
<p className="text-sm text-[var(--text-secondary)]">{plan.description}</p>
</div>
<div className="space-y-3 mb-6">
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuarios`}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Package className="w-4 h-4 text-[var(--color-primary)]" />
<span>{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} productos`}</span>
</div>
</div>
{currentPlan === planKey ? (
<Badge variant="success" className="w-full justify-center py-2">
<CheckCircle className="w-4 h-4 mr-2" />
Plan Actual
</Badge>
) : (
<Button
variant={plan.popular ? 'primary' : 'outline'}
className="w-full"
onClick={() => onUpgrade(planKey)}
>
{plan.contact_sales ? 'Contactar Ventas' : 'Cambiar Plan'}
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</Card>
))}
</div>
);
};
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
const [loading, setLoading] = useState(true);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>('');
const [upgrading, setUpgrading] = useState(false);
useEffect(() => {
if (currentTenant?.id || tenant_id || isMockMode()) {
loadSubscriptionData();
}
}, [currentTenant, tenant_id]);
const loadSubscriptionData = async () => {
let tenantId = currentTenant?.id || tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
console.log('🧪 Mock mode: Using mock tenant ID:', tenantId);
}
console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode());
if (!tenantId) return;
try {
setLoading(true);
const [usage, plans] = await Promise.all([
subscriptionService.getUsageSummary(tenantId),
subscriptionService.getAvailablePlans()
]);
setUsageSummary(usage);
setAvailablePlans(plans);
} catch (error) {
console.error('Error loading subscription data:', error);
toast.error("No se pudo cargar la información de suscripción");
} finally {
setLoading(false);
}
};
const handleUpgradeClick = (planKey: string) => {
setSelectedPlan(planKey);
setUpgradeDialogOpen(true);
};
const handleUpgradeConfirm = async () => {
let tenantId = currentTenant?.id || tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
}
if (!tenantId || !selectedPlan) return;
try {
setUpgrading(true);
const validation = await subscriptionService.validatePlanUpgrade(
tenantId,
selectedPlan
);
if (!validation.can_upgrade) {
toast.error(validation.reason);
return;
}
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
if (result.success) {
toast.success(result.message);
await loadSubscriptionData();
setUpgradeDialogOpen(false);
setSelectedPlan('');
} else {
toast.error('Error al cambiar el plan');
}
} catch (error) {
console.error('Error upgrading plan:', error);
toast.error('Error al procesar el cambio de plan');
} finally {
setUpgrading(false);
}
};
if (loading) {
return (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
loading={loading}
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
</div>
</div>
</div>
);
}
if (!usageSummary || !availablePlans) {
return (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
error="No se pudo cargar la información de suscripción"
onRefresh={loadSubscriptionData}
showRefreshButton
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
<div className="text-center">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
<Button onClick={loadSubscriptionData} variant="primary">
<RefreshCw className="w-4 h-4 mr-2" />
Reintentar
</Button>
</div>
</div>
</div>
</div>
);
}
const nextBillingDate = usageSummary.next_billing_date
? new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
: 'No disponible';
const planInfo = subscriptionService.getPlanDisplayInfo(usageSummary.plan);
return (
<div className="space-y-6">
<PageHeader
title="Suscripción"
subtitle={`Plan ${planInfo.name}`}
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
status={{
text: usageSummary.status === 'active' ? 'Activo' : usageSummary.status,
variant: usageSummary.status === 'active' ? 'success' : 'default'
}}
actions={[
{
id: 'manage-billing',
label: 'Gestionar Facturación',
icon: ExternalLink,
onClick: () => window.open('https://billing.bakery.com', '_blank'),
variant: 'outline'
},
{
id: 'download-invoice',
label: 'Descargar Factura',
icon: Download,
onClick: () => console.log('Download latest invoice'),
variant: 'outline'
}
]}
metadata={[
{
id: 'next-billing',
label: 'Próxima facturación',
value: nextBillingDate,
icon: Calendar
},
{
id: 'monthly-cost',
label: 'Coste mensual',
value: subscriptionService.formatPrice(usageSummary.monthly_price),
icon: CreditCard
}
]}
onRefresh={loadSubscriptionData}
showRefreshButton
/>
{/* Quick Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-500/10 rounded-xl border border-blue-500/20">
<Users className="w-6 h-6 text-blue-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Usuarios</p>
<p className="text-xl font-bold text-[var(--text-primary)]">
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
</p>
</div>
</div>
</Card>
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-green-500/10 rounded-xl border border-green-500/20">
<MapPin className="w-6 h-6 text-green-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Ubicaciones</p>
<p className="text-xl font-bold text-[var(--text-primary)]">
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
</p>
</div>
</div>
</Card>
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-purple-500/10 rounded-xl border border-purple-500/20">
<Package className="w-6 h-6 text-purple-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Productos</p>
<p className="text-xl font-bold text-[var(--text-primary)]">
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
</p>
</div>
</div>
</Card>
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-yellow-500/10 rounded-xl border border-yellow-500/20">
<TrendingUp className="w-6 h-6 text-yellow-500" />
</div>
<div className="flex-1">
<p className="text-sm text-[var(--text-secondary)] mb-1">Estado</p>
<Badge
variant={usageSummary.status === 'active' ? 'success' : 'default'}
className="text-sm font-medium"
>
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
</Badge>
</div>
</div>
</Card>
</div>
<Card className="overflow-hidden">
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">
<TrendingUp className="w-4 h-4 mr-2" />
Resumen
</Tabs.Trigger>
<Tabs.Trigger value="usage">
<Users className="w-4 h-4 mr-2" />
Uso
</Tabs.Trigger>
<Tabs.Trigger value="plans">
<Crown className="w-4 h-4 mr-2" />
Planes
</Tabs.Trigger>
<Tabs.Trigger value="billing">
<CreditCard className="w-4 h-4 mr-2" />
Facturación
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Current Plan Summary */}
<Card className="p-6 lg:col-span-2 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<h3 className="text-lg font-semibold mb-6 flex items-center text-[var(--text-primary)]">
<Crown className="w-5 h-5 mr-2 text-yellow-500" />
Tu Plan Actual
</h3>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Plan</span>
<div className="flex items-center gap-2">
<span className="font-semibold text-[var(--text-primary)]">{planInfo.name}</span>
{usageSummary.plan === 'professional' && (
<Badge variant="primary" size="sm">
<Star className="w-3 h-3 mr-1" />
Popular
</Badge>
)}
</div>
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Precio</span>
<span className="font-semibold text-[var(--text-primary)]">{subscriptionService.formatPrice(usageSummary.monthly_price)}/mes</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Estado</span>
<Badge variant={usageSummary.status === 'active' ? 'success' : 'error'}>
{usageSummary.status === 'active' ? 'Activo' : 'Inactivo'}
</Badge>
</div>
</div>
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--text-secondary)]">Próxima facturación</span>
<span className="font-medium text-[var(--text-primary)]">{nextBillingDate}</span>
</div>
</div>
</div>
</div>
</Card>
{/* Quick Actions */}
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<h3 className="text-lg font-semibold mb-4 text-[var(--text-primary)]">
Acciones Rápidas
</h3>
<div className="space-y-3">
<Button variant="outline" className="w-full justify-start text-sm" onClick={() => window.open('https://billing.bakery.com', '_blank')}>
<ExternalLink className="w-4 h-4 mr-2" />
Portal de Facturación
</Button>
<Button variant="outline" className="w-full justify-start text-sm" onClick={() => console.log('Download invoice')}>
<Download className="w-4 h-4 mr-2" />
Descargar Facturas
</Button>
<Button variant="outline" className="w-full justify-start text-sm">
<Settings className="w-4 h-4 mr-2" />
Configurar Alertas
</Button>
</div>
</Card>
</div>
{/* Usage at a Glance */}
<Card className="p-6 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<h3 className="text-lg font-semibold mb-6 flex items-center text-[var(--text-primary)]">
<TrendingUp className="w-5 h-5 mr-2 text-orange-500" />
Uso de Recursos
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Users */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
<Users className="w-4 h-4 text-blue-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Usuarios</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.users.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.users.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : `${usageSummary.usage.users.limit - usageSummary.usage.users.current} restantes`}</span>
</p>
</div>
{/* Locations */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/10 rounded-lg border border-green-500/20">
<MapPin className="w-4 h-4 text-green-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Ubicaciones</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.locations.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.locations.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : `${usageSummary.usage.locations.limit - usageSummary.usage.locations.current} restantes`}</span>
</p>
</div>
{/* Products */}
<div className="space-y-4 p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/10 rounded-lg border border-purple-500/20">
<Package className="w-4 h-4 text-purple-500" />
</div>
<span className="font-medium text-[var(--text-primary)]">Productos</span>
</div>
<span className="text-sm font-bold text-[var(--text-primary)]">
{usageSummary.usage.products.current}<span className="text-[var(--text-tertiary)]">/{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}</span>
</span>
</div>
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)] flex items-center justify-between">
<span>{usageSummary.usage.products.usage_percentage}% utilizado</span>
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : 'Ilimitado'}</span>
</p>
</div>
</div>
</Card>
</Tabs.Content>
<Tabs.Content value="usage">
<div className="space-y-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">Detalles de Uso</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Users Usage */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-blue-500" />
<h4 className="font-medium text-[var(--text-primary)]">Gestión de Usuarios</h4>
</div>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Usuarios activos</span>
<span className="font-medium">{usageSummary.usage.users.current}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Límite del plan</span>
<span className="font-medium">{usageSummary.usage.users.unlimited ? 'Ilimitado' : usageSummary.usage.users.limit}</span>
</div>
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)]">
{usageSummary.usage.users.usage_percentage}% de capacidad utilizada
</p>
</div>
</div>
{/* Locations Usage */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-green-500" />
<h4 className="font-medium text-[var(--text-primary)]">Ubicaciones</h4>
</div>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Ubicaciones activas</span>
<span className="font-medium">{usageSummary.usage.locations.current}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Límite del plan</span>
<span className="font-medium">{usageSummary.usage.locations.unlimited ? 'Ilimitado' : usageSummary.usage.locations.limit}</span>
</div>
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)]">
{usageSummary.usage.locations.usage_percentage}% de capacidad utilizada
</p>
</div>
</div>
{/* Products Usage */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-purple-500" />
<h4 className="font-medium text-[var(--text-primary)]">Productos</h4>
</div>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Productos registrados</span>
<span className="font-medium">{usageSummary.usage.products.current}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-[var(--text-secondary)]">Límite del plan</span>
<span className="font-medium">{usageSummary.usage.products.unlimited ? 'Ilimitado' : usageSummary.usage.products.limit}</span>
</div>
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
<p className="text-xs text-[var(--text-secondary)]">
{usageSummary.usage.products.usage_percentage}% de capacidad utilizada
</p>
</div>
</div>
</div>
</Card>
</div>
</Tabs.Content>
<Tabs.Content value="plans">
<div className="space-y-6">
<div className="text-center">
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
Planes de Suscripción
</h3>
<p className="text-[var(--text-secondary)]">
Elige el plan que mejor se adapte a las necesidades de tu panadería
</p>
</div>
<PlanComparison
plans={availablePlans.plans}
currentPlan={usageSummary.plan}
onUpgrade={handleUpgradeClick}
/>
</div>
</Tabs.Content>
<Tabs.Content value="billing">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">
Información de Facturación
</h3>
<div className="space-y-4">
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-[var(--text-secondary)]">Plan actual:</span>
<span className="font-medium">{planInfo.name}</span>
</div>
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-[var(--text-secondary)]">Precio mensual:</span>
<span className="font-medium">{subscriptionService.formatPrice(usageSummary.monthly_price)}</span>
</div>
<div className="flex justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<span className="text-[var(--text-secondary)]">Próxima facturación:</span>
<span className="font-medium flex items-center">
<Calendar className="w-4 h-4 mr-2" />
{nextBillingDate}
</span>
</div>
</div>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold mb-6 text-[var(--text-primary)]">
Métodos de Pago
</h3>
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 border border-[var(--border-primary)] rounded-lg">
<CreditCard className="w-8 h-8 text-[var(--text-tertiary)]" />
<div className="flex-1">
<div className="font-medium"> 4242</div>
<div className="text-sm text-[var(--text-secondary)]">Visa terminada en 4242</div>
</div>
<Badge variant="success">Principal</Badge>
</div>
<Button variant="outline" className="w-full">
<Settings className="w-4 h-4 mr-2" />
Gestionar Métodos de Pago
</Button>
</div>
</Card>
</div>
</Tabs.Content>
</Tabs>
</Card>
{/* Upgrade Modal */}
{upgradeDialogOpen && selectedPlan && availablePlans && (
<Modal
isOpen={upgradeDialogOpen}
onClose={() => setUpgradeDialogOpen(false)}
title="Confirmar Cambio de Plan"
>
<div className="space-y-4">
<p className="text-[var(--text-secondary)]">
¿Estás seguro de que quieres cambiar tu plan de suscripción?
</p>
{availablePlans.plans[selectedPlan] && (
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg space-y-2">
<div className="flex justify-between">
<span>Plan actual:</span>
<span>{planInfo.name}</span>
</div>
<div className="flex justify-between">
<span>Nuevo plan:</span>
<span>{availablePlans.plans[selectedPlan].name}</span>
</div>
<div className="flex justify-between font-medium">
<span>Nuevo precio:</span>
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
</div>
</div>
)}
<div className="flex gap-2 pt-4">
<Button
variant="outline"
onClick={() => setUpgradeDialogOpen(false)}
className="flex-1"
>
Cancelar
</Button>
<Button
variant="primary"
onClick={handleUpgradeConfirm}
disabled={upgrading}
className="flex-1"
>
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default SubscriptionPage;

View File

@@ -0,0 +1,718 @@
/**
* Subscription Management Page
* Allows users to view current subscription, billing details, and upgrade plans
*/
import React, { useState, useEffect } from 'react';
import {
Card,
Button,
Badge,
Modal
} from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import {
CreditCard,
Users,
MapPin,
Package,
TrendingUp,
Calendar,
CheckCircle,
AlertCircle,
ArrowRight,
Crown,
Star,
Zap,
X,
RefreshCw,
Settings,
Download,
ExternalLink
} from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth';
import { useBakeryStore } from '../../../../stores/bakery.store';
import { useToast } from '../../../../hooks/ui/useToast';
import {
subscriptionService,
type UsageSummary,
type AvailablePlans
} from '../../../../services/api';
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
interface PlanComparisonProps {
plans: AvailablePlans['plans'];
currentPlan: string;
onUpgrade: (planKey: string) => void;
}
const ProgressBar: React.FC<{ value: number; className?: string }> = ({ value, className = '' }) => {
const getProgressColor = () => {
if (value >= 90) return 'bg-red-500';
if (value >= 80) return 'bg-yellow-500';
return 'bg-green-500';
};
return (
<div className={`w-full bg-gray-200 rounded-full h-2.5 ${className}`}>
<div
className={`${getProgressColor()} h-2.5 rounded-full transition-all duration-300`}
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
};
const Alert: React.FC<{ children: React.ReactNode; className?: string }> = ({
children,
className = ''
}) => {
return (
<div className={`p-4 rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-800 ${className}`}>
{children}
</div>
);
};
const Tabs: React.FC<{ defaultValue: string; className?: string; children: React.ReactNode }> = ({
defaultValue,
className = '',
children
}) => {
const [activeTab, setActiveTab] = useState(defaultValue);
return (
<div className={className}>
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { activeTab, setActiveTab } as any);
}
return child;
})}
</div>
);
};
const TabsList: React.FC<{ children: React.ReactNode; activeTab?: string; setActiveTab?: (tab: string) => void }> = ({
children,
activeTab,
setActiveTab
}) => {
return (
<div className="flex space-x-1 border-b border-gray-200 mb-6">
{React.Children.map(children, child => {
if (React.isValidElement(child)) {
return React.cloneElement(child, { activeTab, setActiveTab } as any);
}
return child;
})}
</div>
);
};
const TabsTrigger: React.FC<{
value: string;
children: React.ReactNode;
activeTab?: string;
setActiveTab?: (tab: string) => void;
}> = ({ value, children, activeTab, setActiveTab }) => {
const isActive = activeTab === value;
return (
<button
className={`px-4 py-2 font-medium text-sm border-b-2 transition-colors ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setActiveTab?.(value)}
>
{children}
</button>
);
};
const TabsContent: React.FC<{
value: string;
children: React.ReactNode;
activeTab?: string;
className?: string;
}> = ({ value, children, activeTab, className = '' }) => {
if (activeTab !== value) return null;
return <div className={className}>{children}</div>;
};
const Separator: React.FC<{ className?: string }> = ({ className = '' }) => {
return <hr className={`border-gray-200 my-4 ${className}`} />;
};
const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onUpgrade }) => {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{Object.entries(plans).map(([key, plan]) => {
const isCurrentPlan = key === currentPlan;
const isPopular = plan.popular;
return (
<div
key={key}
className={`relative rounded-2xl p-6 border-2 transition-all duration-200 ${
isCurrentPlan
? 'border-blue-500 bg-blue-50'
: isPopular
? 'border-purple-200 bg-gradient-to-b from-purple-50 to-white shadow-lg scale-105'
: 'border-gray-200 bg-white hover:border-blue-300 hover:shadow-md'
}`}
>
{isPopular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge className="bg-gradient-to-r from-purple-600 to-purple-700 text-white px-4 py-1">
<Star className="w-3 h-3 mr-1" />
Más Popular
</Badge>
</div>
)}
{isCurrentPlan && (
<div className="absolute top-4 right-4">
<Badge variant="default" className="bg-green-100 text-green-800">
<CheckCircle className="w-3 h-3 mr-1" />
Activo
</Badge>
</div>
)}
<div className="text-center mb-6">
<h3 className="text-2xl font-bold text-gray-900 mb-2">{plan.name}</h3>
<p className="text-gray-600 text-sm mb-4">{plan.description}</p>
<div className="mb-4">
<span className="text-4xl font-bold text-gray-900">
{plan.monthly_price}
</span>
<span className="text-gray-600">/mes</span>
</div>
{plan.trial_available && !isCurrentPlan && (
<p className="text-blue-600 text-sm font-medium">
14 días de prueba gratis
</p>
)}
</div>
<div className="space-y-3 mb-8">
<div className="flex items-center text-sm">
<Users className="w-4 h-4 text-gray-500 mr-3" />
<span>
{plan.max_users === -1 ? 'Usuarios ilimitados' : `Hasta ${plan.max_users} usuarios`}
</span>
</div>
<div className="flex items-center text-sm">
<MapPin className="w-4 h-4 text-gray-500 mr-3" />
<span>
{plan.max_locations === -1 ? 'Ubicaciones ilimitadas' : `${plan.max_locations} ubicación${plan.max_locations > 1 ? 'es' : ''}`}
</span>
</div>
<div className="flex items-center text-sm">
<Package className="w-4 h-4 text-gray-500 mr-3" />
<span>
{plan.max_products === -1 ? 'Productos ilimitados' : `Hasta ${plan.max_products} productos`}
</span>
</div>
{/* Key features */}
<div className="pt-2">
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>
{plan.features.inventory_management === 'basic' && 'Inventario básico'}
{plan.features.inventory_management === 'advanced' && 'Inventario avanzado'}
{plan.features.inventory_management === 'multi_location' && 'Inventario multi-locación'}
</span>
</div>
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>
{plan.features.demand_prediction === 'basic' && 'Predicción básica'}
{plan.features.demand_prediction === 'ai_92_percent' && 'IA con 92% precisión'}
{plan.features.demand_prediction === 'ai_personalized' && 'IA personalizada'}
</span>
</div>
{plan.features.pos_integrated && (
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>POS integrado</span>
</div>
)}
{plan.features.erp_integration && (
<div className="flex items-center text-sm mb-2">
<CheckCircle className="w-4 h-4 text-green-500 mr-3" />
<span>Integración ERP</span>
</div>
)}
{plan.features.account_manager && (
<div className="flex items-center text-sm mb-2">
<Crown className="w-4 h-4 text-yellow-500 mr-3" />
<span>Manager dedicado</span>
</div>
)}
</div>
</div>
<Button
className={`w-full ${
isCurrentPlan
? 'bg-gray-300 text-gray-600 cursor-not-allowed'
: isPopular
? 'bg-gradient-to-r from-purple-600 to-purple-700 hover:from-purple-700 hover:to-purple-800'
: ''
}`}
onClick={() => !isCurrentPlan && onUpgrade(key)}
disabled={isCurrentPlan}
>
{isCurrentPlan ? (
'Plan Actual'
) : plan.contact_sales ? (
'Contactar Ventas'
) : (
<>
Cambiar a {plan.name}
<ArrowRight className="w-4 h-4 ml-2" />
</>
)}
</Button>
</div>
);
})}
</div>
);
};
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
const [loading, setLoading] = useState(true);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<string>('');
const [upgrading, setUpgrading] = useState(false);
useEffect(() => {
if (currentTenant?.id || tenant_id || isMockMode()) {
loadSubscriptionData();
}
}, [currentTenant, tenant_id]);
const loadSubscriptionData = async () => {
let tenantId = currentTenant?.id || tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
console.log('🧪 Mock mode: Using mock tenant ID:', tenantId);
}
console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode());
if (!tenantId) return;
try {
setLoading(true);
const [usage, plans] = await Promise.all([
subscriptionService.getUsageSummary(tenantId),
subscriptionService.getAvailablePlans()
]);
setUsageSummary(usage);
setAvailablePlans(plans);
} catch (error) {
console.error('Error loading subscription data:', error);
toast.error("No se pudo cargar la información de suscripción");
} finally {
setLoading(false);
}
};
const handleUpgradeClick = (planKey: string) => {
setSelectedPlan(planKey);
setUpgradeDialogOpen(true);
};
const handleUpgradeConfirm = async () => {
let tenantId = currentTenant?.id || tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
tenantId = getMockSubscription().tenant_id;
}
if (!tenantId || !selectedPlan) return;
try {
setUpgrading(true);
const validation = await subscriptionService.validatePlanUpgrade(
tenantId,
selectedPlan
);
if (!validation.can_upgrade) {
toast.error(validation.reason);
return;
}
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
if (result.success) {
toast.success(result.message);
await loadSubscriptionData();
setUpgradeDialogOpen(false);
setSelectedPlan('');
} else {
throw new Error(result.message);
}
} catch (error) {
console.error('Error upgrading plan:', error);
toast.error("No se pudo actualizar el plan. Inténtalo de nuevo.");
} finally {
setUpgrading(false);
}
};
const getUsageColor = (percentage: number) => {
if (percentage >= 90) return 'text-red-600';
if (percentage >= 75) return 'text-yellow-600';
return 'text-green-600';
};
if (loading) {
return (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
loading={loading}
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<p className="text-[var(--text-secondary)]">Cargando información de suscripción...</p>
</div>
</div>
</div>
);
}
if (!usageSummary || !availablePlans) {
return (
<div className="space-y-6">
<PageHeader
title="Suscripción"
description="Gestiona tu plan de suscripción y facturación"
icon={CreditCard}
error="No se pudo cargar la información de suscripción"
onRefresh={loadSubscriptionData}
showRefreshButton
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<AlertCircle className="w-12 h-12 text-[var(--text-tertiary)]" />
<div className="text-center">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se pudo cargar la información</h3>
<p className="text-[var(--text-secondary)] mb-4">Hubo un problema al cargar los datos de suscripción</p>
<Button onClick={loadSubscriptionData} variant="primary">
<RefreshCw className="w-4 h-4 mr-2" />
Reintentar
</Button>
</div>
</div>
</div>
</div>
);
}
const nextBillingDate = usageSummary.next_billing_date
? new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES')
: 'N/A';
const trialEndsAt = usageSummary.trial_ends_at
? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES')
: null;
return (
<div className="p-6 max-w-7xl mx-auto space-y-8">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900">Suscripción</h1>
<p className="text-gray-600 mt-2">
Gestiona tu plan, facturación y límites de uso
</p>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview">Resumen</TabsTrigger>
<TabsTrigger value="usage">Uso Actual</TabsTrigger>
<TabsTrigger value="plans">Cambiar Plan</TabsTrigger>
<TabsTrigger value="billing">Facturación</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{/* Current Plan Overview */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold flex items-center">
Plan {subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
{usageSummary.plan === 'professional' && (
<Badge className="ml-2 bg-purple-100 text-purple-800">
<Star className="w-3 h-3 mr-1" />
Más Popular
</Badge>
)}
</h2>
<p className="text-gray-600 mt-1">
{subscriptionService.formatPrice(usageSummary.monthly_price)}/mes
Estado: {usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
</p>
</div>
<div className="text-right">
<div className="text-sm text-gray-500">Próxima facturación</div>
<div className="font-medium">{nextBillingDate}</div>
</div>
</div>
</CardHeader>
<CardContent>
{trialEndsAt && (
<Alert className="mb-4">
<Zap className="h-4 w-4 inline mr-2" />
Tu período de prueba gratuita termina el {trialEndsAt}
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<Users className="w-8 h-8 text-blue-600 mx-auto mb-2" />
<div className="text-2xl font-bold">
{usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
</div>
<div className="text-sm text-gray-600">Usuarios</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<MapPin className="w-8 h-8 text-blue-600 mx-auto mb-2" />
<div className="text-2xl font-bold">
{usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
</div>
<div className="text-sm text-gray-600">Ubicaciones</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<Package className="w-8 h-8 text-blue-600 mx-auto mb-2" />
<div className="text-2xl font-bold">
{usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}
</div>
<div className="text-sm text-gray-600">Productos</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="usage" className="space-y-6">
{/* Usage Details */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<Users className="w-5 h-5 mr-2" />
Usuarios
</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Usado</span>
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.users.usage_percentage)}`}>
{usageSummary.usage.users.current} de {usageSummary.usage.users.unlimited ? '∞' : usageSummary.usage.users.limit}
</span>
</div>
{!usageSummary.usage.users.unlimited && (
<ProgressBar value={usageSummary.usage.users.usage_percentage} />
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<MapPin className="w-5 h-5 mr-2" />
Ubicaciones
</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Usado</span>
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.locations.usage_percentage)}`}>
{usageSummary.usage.locations.current} de {usageSummary.usage.locations.unlimited ? '∞' : usageSummary.usage.locations.limit}
</span>
</div>
{!usageSummary.usage.locations.unlimited && (
<ProgressBar value={usageSummary.usage.locations.usage_percentage} />
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<Package className="w-5 h-5 mr-2" />
Productos
</h3>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Usado</span>
<span className={`text-sm font-medium ${getUsageColor(usageSummary.usage.products.usage_percentage)}`}>
{usageSummary.usage.products.current} de {usageSummary.usage.products.unlimited ? '∞' : usageSummary.usage.products.limit}
</span>
</div>
{!usageSummary.usage.products.unlimited && (
<ProgressBar value={usageSummary.usage.products.usage_percentage} />
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="plans" className="space-y-6">
<div>
<h2 className="text-2xl font-semibold mb-6">Planes Disponibles</h2>
<PlanComparison
plans={availablePlans.plans}
currentPlan={usageSummary.plan}
onUpgrade={handleUpgradeClick}
/>
</div>
</TabsContent>
<TabsContent value="billing" className="space-y-6">
<Card>
<CardHeader>
<h3 className="text-lg font-semibold flex items-center">
<CreditCard className="w-5 h-5 mr-2" />
Información de Facturación
</h3>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-600">Plan Actual</div>
<div className="font-medium">
{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}
</div>
</div>
<div>
<div className="text-sm text-gray-600">Precio Mensual</div>
<div className="font-medium">
{subscriptionService.formatPrice(usageSummary.monthly_price)}
</div>
</div>
<div>
<div className="text-sm text-gray-600">Próxima Facturación</div>
<div className="font-medium flex items-center">
<Calendar className="w-4 h-4 mr-2 text-gray-500" />
{nextBillingDate}
</div>
</div>
<div>
<div className="text-sm text-gray-600">Estado</div>
<Badge
variant="default"
className={usageSummary.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}
>
{usageSummary.status === 'active' ? 'Activo' : usageSummary.status}
</Badge>
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="text-sm font-medium">Próximos Cobros</div>
<div className="text-sm text-gray-600">
Se facturará {subscriptionService.formatPrice(usageSummary.monthly_price)} el {nextBillingDate}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Upgrade Confirmation Modal */}
{upgradeDialogOpen && (
<Modal onClose={() => setUpgradeDialogOpen(false)}>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Confirmar Cambio de Plan</h3>
<button
onClick={() => setUpgradeDialogOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-gray-600 mb-4">
¿Estás seguro de que quieres cambiar al plan{' '}
{selectedPlan && availablePlans?.plans[selectedPlan]?.name}?
</p>
{selectedPlan && availablePlans?.plans[selectedPlan] && (
<div className="py-4 border-t border-b border-gray-200">
<div className="space-y-2">
<div className="flex justify-between">
<span>Plan actual:</span>
<span>{subscriptionService.getPlanDisplayInfo(usageSummary.plan).name}</span>
</div>
<div className="flex justify-between">
<span>Nuevo plan:</span>
<span>{availablePlans.plans[selectedPlan].name}</span>
</div>
<div className="flex justify-between font-medium">
<span>Nuevo precio:</span>
<span>{subscriptionService.formatPrice(availablePlans.plans[selectedPlan].monthly_price)}/mes</span>
</div>
</div>
</div>
)}
<div className="flex justify-end space-x-3 mt-6">
<Button
variant="outline"
onClick={() => setUpgradeDialogOpen(false)}
disabled={upgrading}
>
Cancelar
</Button>
<Button
onClick={handleUpgradeConfirm}
disabled={upgrading}
>
{upgrading ? 'Procesando...' : 'Confirmar Cambio'}
</Button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default SubscriptionPage;

View File

@@ -0,0 +1 @@
export { default } from './SubscriptionPage';

View File

@@ -4,4 +4,5 @@ export * from './public';
// App pages
export { default as DashboardPage } from './app/DashboardPage';
export * from './app/operations';
export * from './app/analytics';
export * from './app/analytics';
export * from './app/settings';

View File

@@ -459,116 +459,312 @@ const LandingPage: React.FC = () => {
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Starter Plan */}
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Starter</h3>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Perfecto para panaderías pequeñas</p>
<div className="mt-6">
<span className="text-3xl font-bold text-[var(--text-primary)]">49</span>
<span className="text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-8 space-y-4">
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Hasta 50 productos</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Predicción básica de demanda</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Control de inventario</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Reportes básicos</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Soporte por email</span>
<div className="group relative bg-[var(--bg-secondary)] rounded-3xl p-8 border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
<div className="absolute top-6 right-6">
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<Package className="w-6 h-6 text-[var(--color-primary)]" />
</div>
</div>
<Button className="w-full mt-8" variant="outline">
<div className="mb-6">
<h3 className="text-2xl font-bold text-[var(--text-primary)]">Starter</h3>
<p className="mt-3 text-[var(--text-secondary)] leading-relaxed">Ideal para panaderías pequeñas o nuevas</p>
</div>
<div className="mb-8">
<div className="flex items-baseline">
<span className="text-5xl font-bold text-[var(--text-primary)]">49</span>
<span className="ml-2 text-lg text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-2 px-3 py-1 bg-[var(--color-success)]/10 text-[var(--color-success)] text-sm font-medium rounded-full inline-block">
14 días gratis
</div>
</div>
<div className="space-y-4 mb-8">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Hasta 50 productos</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Control de inventario básico</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Predicción básica de demanda</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Reportes básicos de producción</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Analytics básicos</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">1 ubicación</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Soporte por email</span>
</div>
</div>
<Button className="w-full py-4 text-base font-semibold border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white transition-all duration-200 group-hover:shadow-lg" variant="outline">
Comenzar Gratis
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
{/* Professional Plan - Highlighted */}
<div className="bg-gradient-to-b from-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-2xl p-8 relative shadow-2xl">
<div className="group relative bg-gradient-to-br from-[var(--color-primary)] via-[var(--color-primary)] to-[var(--color-primary-dark)] rounded-3xl p-8 shadow-2xl transform scale-105 z-10">
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<span className="bg-[var(--color-secondary)] text-white px-4 py-1 rounded-full text-sm font-semibold">
Más Popular
</span>
</div>
<h3 className="text-lg font-semibold text-white">Professional</h3>
<p className="mt-2 text-sm text-white/80">Para panaderías en crecimiento</p>
<div className="mt-6">
<span className="text-3xl font-bold text-white">149</span>
<span className="text-white/80">/mes</span>
</div>
<div className="mt-8 space-y-4">
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Productos ilimitados</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">IA avanzada con 92% precisión</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Gestión completa de producción</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">POS integrado</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Analytics avanzado</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-white mr-3" />
<span className="text-sm text-white">Soporte prioritario 24/7</span>
<div className="bg-gradient-to-r from-[var(--color-secondary)] to-[var(--color-secondary-dark)] text-white px-6 py-2 rounded-full text-sm font-bold shadow-lg">
Más Popular
</div>
</div>
<Button className="w-full mt-8 bg-white text-[var(--color-primary)] hover:bg-[var(--bg-tertiary)]">
<div className="absolute top-6 right-6">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-white" />
</div>
</div>
<div className="mb-6 pt-4">
<h3 className="text-2xl font-bold text-white">Professional</h3>
<p className="mt-3 text-white/90 leading-relaxed">Ideal para panaderías y cadenas en crecimiento</p>
</div>
<div className="mb-8">
<div className="flex items-baseline">
<span className="text-5xl font-bold text-white">129</span>
<span className="ml-2 text-lg text-white/80">/mes</span>
</div>
<div className="mt-2 px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full inline-block">
14 días gratis
</div>
</div>
<div className="space-y-4 mb-8">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Productos ilimitados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Control de inventario avanzado</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">IA Avanzada con 92% de precisión</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Gestión completa de producción</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">POS integrado</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Gestión de Logística Básica</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Analytics avanzados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">1-2 ubicaciones</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-white rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-[var(--color-primary)]" />
</div>
</div>
<span className="ml-3 text-sm text-white font-medium">Soporte prioritario 24/7</span>
</div>
</div>
<Button className="w-full py-4 text-base font-semibold bg-white text-[var(--color-primary)] hover:bg-gray-100 transition-all duration-200 shadow-lg hover:shadow-xl">
Comenzar Prueba Gratuita
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
{/* Enterprise Plan */}
<div className="bg-[var(--bg-secondary)] rounded-2xl p-8 border border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Enterprise</h3>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Para cadenas y grandes operaciones</p>
<div className="mt-6">
<span className="text-3xl font-bold text-[var(--text-primary)]">399</span>
<span className="text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-8 space-y-4">
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Multi-locación ilimitada</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">IA personalizada por ubicación</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">API personalizada</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Integración ERPs</span>
</div>
<div className="flex items-center">
<Check className="w-5 h-5 text-green-500 mr-3" />
<span className="text-sm text-[var(--text-secondary)]">Manager dedicado</span>
<div className="group relative bg-[var(--bg-secondary)] rounded-3xl p-8 border-2 border-[var(--border-primary)] hover:border-[var(--color-accent)]/30 transition-all duration-300 hover:shadow-xl hover:-translate-y-1">
<div className="absolute top-6 right-6">
<div className="w-12 h-12 bg-[var(--color-accent)]/10 rounded-full flex items-center justify-center">
<Settings className="w-6 h-6 text-[var(--color-accent)]" />
</div>
</div>
<Button className="w-full mt-8" variant="outline">
<div className="mb-6">
<h3 className="text-2xl font-bold text-[var(--text-primary)]">Enterprise</h3>
<p className="mt-3 text-[var(--text-secondary)] leading-relaxed">Ideal para cadenas con obradores centrales</p>
</div>
<div className="mb-8">
<div className="flex items-baseline">
<span className="text-5xl font-bold text-[var(--text-primary)]">399</span>
<span className="ml-2 text-lg text-[var(--text-secondary)]">/mes</span>
</div>
<div className="mt-2 px-3 py-1 bg-[var(--color-accent)]/10 text-[var(--color-accent)] text-sm font-medium rounded-full inline-block">
Demo personalizada
</div>
</div>
<div className="space-y-4 mb-8 max-h-80 overflow-y-auto pr-2 scrollbar-thin">
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Productos ilimitados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Control de inventario multi-locación</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">IA personalizada por ubicación</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Optimización de capacidad</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Integración con ERPs</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Gestión de Logística Avanzada</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Analytics predictivos</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Ubicaciones y obradores ilimitados</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">API Personalizada</span>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className="w-5 h-5 bg-[var(--color-accent)] rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
</div>
<span className="ml-3 text-sm text-[var(--text-primary)] font-medium">Manager de Cuenta Dedicado</span>
</div>
</div>
<Button className="w-full py-4 text-base font-semibold border-2 border-[var(--color-accent)] text-[var(--color-accent)] hover:bg-[var(--color-accent)] hover:text-white transition-all duration-200 group-hover:shadow-lg" variant="outline">
Contactar Ventas
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</div>

View File

@@ -35,6 +35,7 @@ const BakeryConfigPage = React.lazy(() => import('../pages/app/settings/bakery-c
const SystemSettingsPage = React.lazy(() => import('../pages/app/settings/system/SystemSettingsPage'));
const TeamPage = React.lazy(() => import('../pages/app/settings/team/TeamPage'));
const TrainingPage = React.lazy(() => import('../pages/app/settings/training/TrainingPage'));
const SubscriptionPage = React.lazy(() => import('../pages/app/settings/subscription/SubscriptionPage'));
// Data pages
const WeatherPage = React.lazy(() => import('../pages/app/data/weather/WeatherPage'));
@@ -265,6 +266,16 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute>
}
/>
<Route
path="/app/settings/subscription"
element={
<ProtectedRoute>
<AppShell>
<SubscriptionPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Data Routes */}
<Route

View File

@@ -123,6 +123,7 @@ export const ROUTES = {
SETTINGS_INTEGRATIONS: '/settings/integrations',
SETTINGS_PREFERENCES: '/settings/preferences',
SETTINGS_BILLING: '/settings/billing',
SETTINGS_SUBSCRIPTION: '/app/settings/subscription',
// Reports
REPORTS: '/reports',
@@ -399,6 +400,17 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true,
showInBreadcrumbs: true,
},
{
path: '/app/settings/subscription',
name: 'Subscription',
component: 'SubscriptionPage',
title: 'Suscripción y Facturación',
icon: 'credit-card',
requiresAuth: true,
requiredRoles: ['admin', 'owner'],
showInNavigation: true,
showInBreadcrumbs: true,
},
],
},

View File

@@ -218,4 +218,5 @@ class ApiClient {
}
}
export { ApiClient };
export const apiClient = new ApiClient();

View File

@@ -1,5 +1,6 @@
// Export API client and types
export * from './client';
export { ApiClient } from './client';
// Export all services
export * from './auth.service';
@@ -14,6 +15,7 @@ export * from './pos.service';
export * from './data.service';
export * from './training.service';
export * from './notification.service';
export * from './subscription.service';
// Service instances for easy importing
export { authService } from './auth.service';
@@ -28,6 +30,7 @@ export { posService } from './pos.service';
export { dataService } from './data.service';
export { trainingService } from './training.service';
export { notificationService } from './notification.service';
export { subscriptionService } from './subscription.service';
// API client instance
export { apiClient } from './client';

View File

@@ -0,0 +1,481 @@
/**
* Subscription Service
* Handles API calls for subscription management, billing, and plan limits
*/
import { ApiClient } from './client';
import { isMockMode, getMockSubscription } from '../../config/mock.config';
export interface SubscriptionLimits {
plan: string;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
}
export interface UsageSummary {
plan: string;
monthly_price: number;
status: string;
usage: {
users: {
current: number;
limit: number;
unlimited: boolean;
usage_percentage: number;
};
locations: {
current: number;
limit: number;
unlimited: boolean;
usage_percentage: number;
};
products: {
current: number;
limit: number;
unlimited: boolean;
usage_percentage: number;
};
};
features: Record<string, any>;
next_billing_date?: string;
trial_ends_at?: string;
}
export interface LimitCheckResult {
can_add: boolean;
current_count?: number;
max_allowed?: number;
reason: string;
}
export interface FeatureCheckResult {
has_feature: boolean;
feature_value?: any;
plan: string;
reason: string;
}
export interface PlanUpgradeValidation {
can_upgrade: boolean;
current_plan?: string;
new_plan?: string;
price_change?: number;
new_features?: Record<string, any>;
reason: string;
}
export interface AvailablePlan {
name: string;
description: string;
monthly_price: number;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
trial_available: boolean;
popular?: boolean;
contact_sales?: boolean;
}
export interface AvailablePlans {
plans: Record<string, AvailablePlan>;
}
export interface BillingHistoryItem {
id: string;
date: string;
amount: number;
status: 'paid' | 'pending' | 'failed';
description: string;
}
export interface SubscriptionData {
id: string;
tenant_id: string;
plan: string;
status: string;
monthly_price: number;
currency: string;
billing_cycle: string;
current_period_start: string;
current_period_end: string;
next_billing_date: string;
trial_ends_at?: string | null;
canceled_at?: string | null;
created_at: string;
updated_at: string;
max_users: number;
max_locations: number;
max_products: number;
features: Record<string, any>;
usage: {
users: number;
locations: number;
products: number;
storage_gb: number;
api_calls_month: number;
reports_generated: number;
};
billing_history: BillingHistoryItem[];
}
class SubscriptionService {
private apiClient: ApiClient;
constructor() {
this.apiClient = new ApiClient();
}
/**
* Get current subscription limits for a tenant
*/
async getSubscriptionLimits(tenantId: string): Promise<SubscriptionLimits> {
if (isMockMode()) {
const mockSub = getMockSubscription();
return {
plan: mockSub.plan,
max_users: mockSub.max_users,
max_locations: mockSub.max_locations,
max_products: mockSub.max_products,
features: mockSub.features
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/limits`);
return response.data;
}
/**
* Get usage summary vs limits for a tenant
*/
async getUsageSummary(tenantId: string): Promise<UsageSummary> {
if (isMockMode()) {
console.log('🧪 Mock mode: Returning usage summary for tenant:', tenantId);
const mockSub = getMockSubscription();
return {
plan: mockSub.plan,
monthly_price: mockSub.monthly_price,
status: mockSub.status,
usage: {
users: {
current: mockSub.usage.users,
limit: mockSub.max_users,
unlimited: mockSub.max_users === -1,
usage_percentage: mockSub.max_users === -1 ? 0 : Math.round((mockSub.usage.users / mockSub.max_users) * 100)
},
locations: {
current: mockSub.usage.locations,
limit: mockSub.max_locations,
unlimited: mockSub.max_locations === -1,
usage_percentage: mockSub.max_locations === -1 ? 0 : Math.round((mockSub.usage.locations / mockSub.max_locations) * 100)
},
products: {
current: mockSub.usage.products,
limit: mockSub.max_products,
unlimited: mockSub.max_products === -1,
usage_percentage: mockSub.max_products === -1 ? 0 : Math.round((mockSub.usage.products / mockSub.max_products) * 100)
}
},
features: mockSub.features,
next_billing_date: mockSub.next_billing_date,
trial_ends_at: mockSub.trial_ends_at
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/usage`);
return response.data;
}
/**
* Check if tenant can add another location
*/
async canAddLocation(tenantId: string): Promise<LimitCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const canAdd = mockSub.max_locations === -1 || mockSub.usage.locations < mockSub.max_locations;
return {
can_add: canAdd,
current_count: mockSub.usage.locations,
max_allowed: mockSub.max_locations,
reason: canAdd ? 'Can add more locations' : 'Location limit reached. Upgrade plan to add more locations.'
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-location`);
return response.data;
}
/**
* Check if tenant can add another product
*/
async canAddProduct(tenantId: string): Promise<LimitCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const canAdd = mockSub.max_products === -1 || mockSub.usage.products < mockSub.max_products;
return {
can_add: canAdd,
current_count: mockSub.usage.products,
max_allowed: mockSub.max_products,
reason: canAdd ? 'Can add more products' : 'Product limit reached. Upgrade plan to add more products.'
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-product`);
return response.data;
}
/**
* Check if tenant can add another user/member
*/
async canAddUser(tenantId: string): Promise<LimitCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const canAdd = mockSub.max_users === -1 || mockSub.usage.users < mockSub.max_users;
return {
can_add: canAdd,
current_count: mockSub.usage.users,
max_allowed: mockSub.max_users,
reason: canAdd ? 'Can add more users' : 'User limit reached. Upgrade plan to add more users.'
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/can-add-user`);
return response.data;
}
/**
* Check if tenant has access to a specific feature
*/
async hasFeature(tenantId: string, feature: string): Promise<FeatureCheckResult> {
if (isMockMode()) {
const mockSub = getMockSubscription();
const hasFeature = feature in mockSub.features;
return {
has_feature: hasFeature,
feature_value: hasFeature ? mockSub.features[feature] : null,
plan: mockSub.plan,
reason: hasFeature ? `Feature ${feature} is available in ${mockSub.plan} plan` : `Feature ${feature} is not available in ${mockSub.plan} plan`
};
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/features/${feature}`);
return response.data;
}
/**
* Validate if tenant can upgrade to a new plan
*/
async validatePlanUpgrade(tenantId: string, newPlan: string): Promise<PlanUpgradeValidation> {
const response = await this.apiClient.get(`/subscriptions/${tenantId}/validate-upgrade/${newPlan}`);
return response.data;
}
/**
* Upgrade subscription plan for a tenant
*/
async upgradePlan(tenantId: string, newPlan: string): Promise<{
success: boolean;
message: string;
validation: PlanUpgradeValidation;
}> {
const response = await this.apiClient.post(`/subscriptions/${tenantId}/upgrade`, null, {
params: { new_plan: newPlan }
});
return response.data;
}
/**
* Get full subscription data including billing history for admin@bakery.com
*/
async getSubscriptionData(tenantId: string): Promise<SubscriptionData> {
if (isMockMode()) {
return getMockSubscription();
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/details`);
return response.data;
}
/**
* Get billing history for a subscription
*/
async getBillingHistory(tenantId: string): Promise<BillingHistoryItem[]> {
if (isMockMode()) {
return getMockSubscription().billing_history;
}
const response = await this.apiClient.get(`/subscriptions/${tenantId}/billing-history`);
return response.data;
}
/**
* Get all available subscription plans with features and pricing
*/
async getAvailablePlans(): Promise<AvailablePlans> {
if (isMockMode()) {
return {
plans: {
starter: {
name: 'Starter',
description: 'Perfecto para panaderías pequeñas que están comenzando',
monthly_price: 49.0,
max_users: 5,
max_locations: 1,
max_products: 50,
features: {
inventory_management: 'basic',
demand_prediction: 'basic',
production_reports: 'basic',
analytics: 'basic',
support: 'email',
trial_days: 14,
locations: '1_location'
},
trial_available: true
},
professional: {
name: 'Professional',
description: 'Para panaderías en crecimiento que necesitan más control',
monthly_price: 129.0,
max_users: 15,
max_locations: 2,
max_products: -1,
features: {
inventory_management: 'advanced',
demand_prediction: 'ai_92_percent',
production_management: 'complete',
pos_integrated: true,
logistics: 'basic',
analytics: 'advanced',
support: 'priority_24_7',
trial_days: 14,
locations: '1_2_locations'
},
trial_available: true,
popular: true
},
enterprise: {
name: 'Enterprise',
description: 'Para cadenas de panaderías con necesidades avanzadas',
monthly_price: 399.0,
max_users: -1,
max_locations: -1,
max_products: -1,
features: {
inventory_management: 'multi_location',
demand_prediction: 'ai_personalized',
production_optimization: 'capacity',
erp_integration: true,
logistics: 'advanced',
analytics: 'predictive',
api_access: 'personalized',
account_manager: true,
demo: 'personalized',
locations: 'unlimited_obradores'
},
trial_available: false,
contact_sales: true
}
}
};
}
const response = await this.apiClient.get('/plans/available');
return response.data;
}
/**
* Helper method to check if a feature is enabled for current tenant
*/
async isFeatureEnabled(tenantId: string, feature: string): Promise<boolean> {
try {
const result = await this.hasFeature(tenantId, feature);
return result.has_feature;
} catch (error) {
console.error(`Error checking feature ${feature}:`, error);
return false;
}
}
/**
* Helper method to get feature level (basic, advanced, etc.)
*/
async getFeatureLevel(tenantId: string, feature: string): Promise<string | null> {
try {
const result = await this.hasFeature(tenantId, feature);
return result.feature_value || null;
} catch (error) {
console.error(`Error getting feature level for ${feature}:`, error);
return null;
}
}
/**
* Helper method to check if usage is approaching limits
*/
async isUsageNearLimit(tenantId: string, threshold: number = 80): Promise<{
users: boolean;
locations: boolean;
products: boolean;
}> {
try {
const usage = await this.getUsageSummary(tenantId);
return {
users: !usage.usage.users.unlimited && usage.usage.users.usage_percentage >= threshold,
locations: !usage.usage.locations.unlimited && usage.usage.locations.usage_percentage >= threshold,
products: !usage.usage.products.unlimited && usage.usage.products.usage_percentage >= threshold,
};
} catch (error) {
console.error('Error checking usage limits:', error);
return { users: false, locations: false, products: false };
}
}
/**
* Helper method to format pricing for display
*/
formatPrice(price: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(price);
}
/**
* Helper method to get plan display information
*/
getPlanDisplayInfo(plan: string): {
name: string;
color: string;
badge?: string;
} {
const planInfo = {
starter: {
name: 'Starter',
color: 'blue',
},
professional: {
name: 'Professional',
color: 'purple',
badge: 'Más Popular'
},
enterprise: {
name: 'Enterprise',
color: 'gold',
}
};
return planInfo[plan as keyof typeof planInfo] || {
name: plan,
color: 'gray'
};
}
}
export const subscriptionService = new SubscriptionService();
export default subscriptionService;