Improve subcription support
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
7
frontend/src/pages/app/settings/index.ts
Normal file
7
frontend/src/pages/app/settings/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
1
frontend/src/pages/app/settings/subscription/index.ts
Normal file
1
frontend/src/pages/app/settings/subscription/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './SubscriptionPage';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -218,4 +218,5 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export { ApiClient };
|
||||
export const apiClient = new ApiClient();
|
||||
@@ -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';
|
||||
481
frontend/src/services/api/subscription.service.ts
Normal file
481
frontend/src/services/api/subscription.service.ts
Normal 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;
|
||||
Reference in New Issue
Block a user