Add role-based filtering and imporve code

This commit is contained in:
Urtzi Alfaro
2025-10-15 16:12:49 +02:00
parent 96ad5c6692
commit 8f9e9a7edc
158 changed files with 11033 additions and 1544 deletions

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { Modal, Button, Card } from '../ui';
import { Crown, Lock, ArrowRight, AlertTriangle } from 'lucide-react';
import {
SUBSCRIPTION_PLANS,
SUBSCRIPTION_TIERS,
ANALYTICS_LEVELS
} from '../../api/types/subscription';
import { subscriptionService } from '../../api/services/subscription';
@@ -59,19 +59,19 @@ const SubscriptionErrorHandler: React.FC<SubscriptionErrorHandlerProps> = ({
const getRequiredPlan = (level: string) => {
switch (level) {
case ANALYTICS_LEVELS.ADVANCED:
return SUBSCRIPTION_PLANS.PROFESSIONAL;
return SUBSCRIPTION_TIERS.PROFESSIONAL;
case ANALYTICS_LEVELS.PREDICTIVE:
return SUBSCRIPTION_PLANS.ENTERPRISE;
return SUBSCRIPTION_TIERS.ENTERPRISE;
default:
return SUBSCRIPTION_PLANS.PROFESSIONAL;
return SUBSCRIPTION_TIERS.PROFESSIONAL;
}
};
const getPlanColor = (plan: string) => {
switch (plan.toLowerCase()) {
case SUBSCRIPTION_PLANS.PROFESSIONAL:
case SUBSCRIPTION_TIERS.PROFESSIONAL:
return 'bg-gradient-to-br from-purple-500 to-indigo-600';
case SUBSCRIPTION_PLANS.ENTERPRISE:
case SUBSCRIPTION_TIERS.ENTERPRISE:
return 'bg-gradient-to-br from-yellow-400 to-orange-500';
default:
return 'bg-gradient-to-br from-blue-500 to-cyan-600';

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Button, Badge } from '../../ui';
import { CheckCircle, Users, MapPin, Package, TrendingUp, Star, ArrowRight } from 'lucide-react';
import { subscriptionService, type AvailablePlans } from '../../../api';
import { CheckCircle, Users, MapPin, Package, TrendingUp, Star, ArrowRight, Zap } from 'lucide-react';
import { subscriptionService, type AvailablePlans, type PlanMetadata, SUBSCRIPTION_TIERS } from '../../../api';
interface SubscriptionSelectionProps {
selectedPlan: string;
@@ -24,14 +24,18 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
const { t } = useTranslation();
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPlans = async () => {
try {
const plans = await subscriptionService.getAvailablePlans();
setLoading(true);
setError(null);
const plans = await subscriptionService.fetchAvailablePlans();
setAvailablePlans(plans);
} catch (error) {
console.error('Error fetching subscription plans:', error);
} catch (err) {
console.error('Error fetching subscription plans:', err);
setError('No se pudieron cargar los planes. Por favor, intenta de nuevo.');
} finally {
setLoading(false);
}
@@ -40,7 +44,7 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
fetchPlans();
}, []);
if (loading || !availablePlans) {
if (loading) {
return (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-color-primary"></div>
@@ -48,19 +52,107 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
);
}
if (error || !availablePlans) {
return (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="text-color-error text-center">
<p className="font-semibold">{error || 'Error al cargar los planes'}</p>
</div>
<Button
variant="outline"
onClick={() => window.location.reload()}
>
Intentar de nuevo
</Button>
</div>
);
}
const handleTrialToggle = () => {
if (onTrialSelect) {
onTrialSelect(!trialSelected);
}
};
// Helper function to translate feature names to Spanish
const translateFeature = (feature: string): string => {
const translations: Record<string, string> = {
'inventory_management': 'Gestión de inventario',
'sales_tracking': 'Seguimiento de ventas',
'basic_analytics': 'Analíticas básicas',
'basic_forecasting': 'Pronósticos básicos',
'pos_integration': 'Punto de venta integrado',
'production_planning': 'Planificación de producción',
'supplier_management': 'Gestión de proveedores',
'recipe_management': 'Gestión de recetas',
'advanced_analytics': 'Analíticas avanzadas',
'ai_forecasting': 'Pronósticos con IA',
'weather_data_integration': 'Integración datos meteorológicos',
'multi_location': 'Multi-ubicación',
'custom_reports': 'Reportes personalizados',
'api_access': 'Acceso API',
'priority_support': 'Soporte prioritario',
'dedicated_account_manager': 'Manager de cuenta dedicado',
'sla_guarantee': 'Garantía SLA',
'custom_integrations': 'Integraciones personalizadas',
'white_label': 'Marca blanca',
'advanced_security': 'Seguridad avanzada',
'audit_logs': 'Registros de auditoría',
'role_based_access': 'Control de acceso basado en roles',
'custom_workflows': 'Flujos de trabajo personalizados',
'training_sessions': 'Sesiones de capacitación',
'onboarding_support': 'Soporte de incorporación',
'data_export': 'Exportación de datos',
'backup_restore': 'Respaldo y restauración',
'mobile_app': 'Aplicación móvil',
'offline_mode': 'Modo offline',
'real_time_sync': 'Sincronización en tiempo real',
'notifications': 'Notificaciones',
'email_alerts': 'Alertas por email',
'sms_alerts': 'Alertas por SMS',
'inventory_alerts': 'Alertas de inventario',
'low_stock_alerts': 'Alertas de stock bajo',
'expiration_tracking': 'Seguimiento de caducidad',
'batch_tracking': 'Seguimiento de lotes',
'quality_control': 'Control de calidad',
'compliance_reporting': 'Reportes de cumplimiento',
'financial_reports': 'Reportes financieros',
'tax_reports': 'Reportes de impuestos',
'waste_tracking': 'Seguimiento de desperdicios',
'cost_analysis': 'Análisis de costos',
'profit_margins': 'Márgenes de ganancia',
'sales_forecasting': 'Pronóstico de ventas',
'demand_planning': 'Planificación de demanda',
'seasonal_trends': 'Tendencias estacionales',
'customer_analytics': 'Analíticas de clientes',
'loyalty_program': 'Programa de lealtad',
'discount_management': 'Gestión de descuentos',
'promotion_tracking': 'Seguimiento de promociones',
'gift_cards': 'Tarjetas de regalo',
'online_ordering': 'Pedidos en línea',
'delivery_management': 'Gestión de entregas',
'route_optimization': 'Optimización de rutas',
'driver_tracking': 'Seguimiento de conductores',
'customer_portal': 'Portal de clientes',
'vendor_portal': 'Portal de proveedores',
'invoice_management': 'Gestión de facturas',
'payment_processing': 'Procesamiento de pagos',
'purchase_orders': 'Órdenes de compra',
'receiving_management': 'Gestión de recepciones'
};
return translations[feature] || feature.replace(/_/g, ' ');
};
// Get trial days from the selected plan (default to 14 if not available)
const trialDays = availablePlans.plans[selectedPlan]?.trial_days || 14;
return (
<div className={`space-y-4 ${className}`}>
{showTrialOption && (
<Card className="p-4 border-2 border-color-primary/30 bg-bg-primary">
<Card className="p-4 border-2 border-color-primary/30 bg-gradient-to-r from-color-primary/5 to-color-primary/10">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
<div className="p-2.5 bg-color-primary/10 rounded-lg flex-shrink-0">
<div className="p-2.5 bg-color-primary/20 rounded-lg flex-shrink-0">
<Star className="w-5 h-5 text-color-primary" />
</div>
<div className="flex-1 min-w-0">
@@ -68,7 +160,7 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
{t('auth:subscription.trial_title', 'Prueba gratuita')}
</h3>
<p className="text-sm text-text-secondary">
{t('auth:subscription.trial_description', 'Obtén 3 meses de prueba gratuita como usuario piloto')}
{t('auth:subscription.trial_description', `Obtén ${trialDays} días de prueba gratuita - sin tarjeta de crédito requerida`)}
</p>
</div>
</div>
@@ -78,9 +170,14 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
onClick={handleTrialToggle}
className="w-full sm:w-auto flex-shrink-0 min-w-[100px]"
>
{trialSelected
? t('auth:subscription.trial_active', 'Activo')
: t('auth:subscription.trial_activate', 'Activar')}
{trialSelected ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
<span>{t('auth:subscription.trial_active', 'Activo')}</span>
</div>
) : (
t('auth:subscription.trial_activate', 'Activar')
)}
</Button>
</div>
</Card>
@@ -89,19 +186,20 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
<div className="space-y-3">
{Object.entries(availablePlans.plans).map(([planKey, plan]) => {
const isSelected = selectedPlan === planKey;
const metadata = plan as PlanMetadata;
return (
<Card
key={planKey}
className={`relative p-6 cursor-pointer transition-all duration-200 border-2 ${
isSelected
? 'border-color-primary bg-color-primary/5 shadow-lg'
? 'border-color-primary bg-color-primary/5 shadow-lg ring-2 ring-color-primary/20'
: 'border-border-primary bg-bg-primary hover:border-color-primary/40 hover:shadow-md'
} ${plan.popular ? 'pt-8' : ''}`}
} ${metadata.popular ? 'pt-8' : ''}`}
onClick={() => onPlanSelect(planKey)}
>
{/* Popular Badge */}
{plan.popular && (
{metadata.popular && (
<div className="absolute top-0 left-0 right-0 flex justify-center -translate-y-1/2 z-20">
<Badge variant="primary" className="px-4 py-1.5 text-xs font-bold flex items-center gap-1.5 shadow-lg rounded-full">
<Star className="w-3.5 h-3.5 fill-current" />
@@ -115,14 +213,28 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
{/* Header Section: Plan Info & Pricing */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex-1">
<h4 className="text-2xl font-bold text-text-primary mb-2">{plan.name}</h4>
<div className="flex items-center gap-2 mb-2">
<h4 className="text-2xl font-bold text-text-primary">{metadata.name}</h4>
{metadata.trial_days > 0 && (
<Badge variant="success" className="text-xs px-2 py-0.5">
<Zap className="w-3 h-3 mr-1" />
{metadata.trial_days} días gratis
</Badge>
)}
</div>
<p className="text-sm text-color-primary font-semibold mb-3">{metadata.tagline}</p>
<div className="flex items-baseline gap-1 mb-3">
<span className="text-4xl font-bold text-color-primary">
{subscriptionService.formatPrice(plan.monthly_price)}
{subscriptionService.formatPrice(metadata.monthly_price)}
</span>
<span className="text-base text-text-secondary font-medium">/mes</span>
</div>
<p className="text-sm text-text-secondary leading-relaxed max-w-prose">{plan.description}</p>
<p className="text-sm text-text-secondary leading-relaxed max-w-prose">{metadata.description}</p>
{metadata.recommended_for && (
<p className="text-xs text-text-tertiary mt-2 italic">
💡 {metadata.recommended_for}
</p>
)}
</div>
{/* Action Button - Desktop position */}
@@ -155,71 +267,73 @@ export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-4 border-t border-border-primary/50">
{/* Plan Limits */}
<div className="space-y-3">
<div className="flex items-center gap-2.5 text-sm text-text-primary">
<Users className="w-4 h-4 text-color-primary flex-shrink-0" />
<span className="font-medium">{plan.max_users === -1 ? 'Usuarios ilimitados' : `${plan.max_users} usuario${plan.max_users > 1 ? 's' : ''}`}</span>
<div className="flex items-center gap-2 mb-3">
<Package className="w-5 h-5 text-color-primary flex-shrink-0" />
<h5 className="text-base font-bold text-text-primary">
Límites del Plan
</h5>
</div>
<div className="flex items-center gap-2.5 text-sm text-text-primary">
<MapPin className="w-4 h-4 text-color-primary flex-shrink-0" />
<span className="font-medium">{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.5 text-sm text-text-primary">
<Package className="w-4 h-4 text-color-primary flex-shrink-0" />
<span className="font-medium">{plan.max_products === -1 ? 'Productos ilimitados' : `${plan.max_products} producto${plan.max_products > 1 ? 's' : ''}`}</span>
<div className="space-y-2.5">
<div className="flex items-center gap-2.5 text-sm text-text-primary">
<Users className="w-4 h-4 text-color-accent flex-shrink-0" />
<span className="font-medium">
{metadata.limits.users === null ? 'Usuarios ilimitados' : `${metadata.limits.users} usuario${metadata.limits.users > 1 ? 's' : ''}`}
</span>
</div>
<div className="flex items-center gap-2.5 text-sm text-text-primary">
<MapPin className="w-4 h-4 text-color-accent flex-shrink-0" />
<span className="font-medium">
{metadata.limits.locations === null ? 'Ubicaciones ilimitadas' : `${metadata.limits.locations} ubicación${metadata.limits.locations > 1 ? 'es' : ''}`}
</span>
</div>
<div className="flex items-center gap-2.5 text-sm text-text-primary">
<Package className="w-4 h-4 text-color-accent flex-shrink-0" />
<span className="font-medium">
{metadata.limits.products === null ? 'Productos ilimitados' : `${metadata.limits.products} producto${metadata.limits.products > 1 ? 's' : ''}`}
</span>
</div>
{metadata.limits.forecasts_per_day !== null && (
<div className="flex items-center gap-2.5 text-sm text-text-primary">
<TrendingUp className="w-4 h-4 text-color-accent flex-shrink-0" />
<span className="font-medium">
{metadata.limits.forecasts_per_day} pronóstico{metadata.limits.forecasts_per_day > 1 ? 's' : ''}/día
</span>
</div>
)}
</div>
</div>
{/* Features */}
<div className="space-y-3 lg:pl-6 lg:border-l border-border-primary/50">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-5 h-5 text-color-primary flex-shrink-0" />
<CheckCircle className="w-5 h-5 text-color-success flex-shrink-0" />
<h5 className="text-base font-bold text-text-primary">
{t('auth:subscription.features', 'Funcionalidades Incluidas')}
</h5>
</div>
<div className="space-y-2.5">
{(() => {
const getPlanFeatures = (planKey: string) => {
switch (planKey) {
case 'starter':
return [
'Panel de Control Básico',
'Gestión de Inventario',
'Gestión de Pedidos',
'Gestión de Proveedores',
'Punto de Venta Básico'
];
case 'professional':
return [
'Todo lo de Starter',
'Panel Avanzado',
'Analytics de Ventas',
'Pronósticos con IA',
'Optimización de Producción'
];
case 'enterprise':
return [
'Todo lo de Professional',
'Insights Predictivos IA',
'Analytics Multi-ubicación',
'Integración ERP',
'Soporte 24/7 Prioritario',
'API Personalizada'
];
default:
return [];
}
};
return getPlanFeatures(planKey).map((feature, index) => (
<div key={index} className="flex items-start gap-2.5 text-sm">
<CheckCircle className="w-4 h-4 text-color-success flex-shrink-0 mt-0.5" />
<span className="text-text-primary leading-snug">{feature}</span>
</div>
));
})()}
<div className="space-y-2.5 max-h-48 overflow-y-auto pr-2 scrollbar-thin">
{metadata.features.slice(0, 8).map((feature, index) => (
<div key={index} className="flex items-start gap-2.5 text-sm">
<CheckCircle className="w-4 h-4 text-color-success flex-shrink-0 mt-0.5" />
<span className="text-text-primary leading-snug">{translateFeature(feature)}</span>
</div>
))}
{metadata.features.length > 8 && (
<p className="text-xs text-text-tertiary italic pl-6">
+{metadata.features.length - 8} funcionalidades más
</p>
)}
</div>
{/* Support Level */}
{metadata.support && (
<div className="pt-3 mt-3 border-t border-border-primary/30">
<p className="text-xs text-text-secondary">
<span className="font-semibold">Soporte:</span> {metadata.support}
</p>
</div>
)}
</div>
</div>

View File

@@ -1,7 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateTrainingJob, useTrainingWebSocket, useTrainingJobStatus } from '../../../../api/hooks/training';
import { Info } from 'lucide-react';
interface MLTrainingStepProps {
onNext: () => void;
@@ -22,14 +25,33 @@ interface TrainingProgress {
export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
onComplete
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [trainingProgress, setTrainingProgress] = useState<TrainingProgress | null>(null);
const [isTraining, setIsTraining] = useState(false);
const [error, setError] = useState<string>('');
const [jobId, setJobId] = useState<string | null>(null);
const [trainingStartTime, setTrainingStartTime] = useState<number | null>(null);
const [showSkipOption, setShowSkipOption] = useState(false);
const currentTenant = useCurrentTenant();
const createTrainingJob = useCreateTrainingJob();
// Check if training has been running for more than 2 minutes
useEffect(() => {
if (trainingStartTime && isTraining && !showSkipOption) {
const checkTimer = setInterval(() => {
const elapsedTime = (Date.now() - trainingStartTime) / 1000; // in seconds
if (elapsedTime > 120) { // 2 minutes
setShowSkipOption(true);
clearInterval(checkTimer);
}
}, 5000); // Check every 5 seconds
return () => clearInterval(checkTimer);
}
}, [trainingStartTime, isTraining, showSkipOption]);
// Memoized WebSocket callbacks to prevent reconnections
const handleProgress = useCallback((data: any) => {
setTrainingProgress({
@@ -37,7 +59,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
progress: data.data?.progress || 0,
message: data.data?.message || 'Entrenando modelo...',
currentStep: data.data?.current_step,
estimatedTimeRemaining: data.data?.estimated_time_remaining
estimatedTimeRemaining: data.data?.estimated_time_remaining_seconds || data.data?.estimated_time_remaining
});
}, []);
@@ -177,7 +199,8 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
});
setJobId(response.job_id);
setTrainingStartTime(Date.now()); // Track when training started
setTrainingProgress({
stage: 'queued',
progress: 10,
@@ -190,6 +213,12 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
}
};
const handleSkipToDashboard = () => {
// Navigate to dashboard while training continues in background
console.log('🚀 User chose to skip to dashboard while training continues');
navigate('/app/dashboard');
};
const formatTime = (seconds?: number) => {
if (!seconds) return '';
@@ -273,7 +302,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
</div>
<div className="flex justify-between text-xs text-[var(--text-tertiary)]">
<span>{trainingProgress.currentStep || 'Procesando...'}</span>
<span>{trainingProgress.currentStep || t('onboarding:steps.ml_training.progress.data_preparation', 'Procesando...')}</span>
<div className="flex items-center gap-2">
{jobId && (
<span className={`text-xs ${isConnected ? 'text-green-500' : 'text-red-500'}`}>
@@ -281,7 +310,7 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
</span>
)}
{trainingProgress.estimatedTimeRemaining && (
<span>Tiempo estimado: {formatTime(trainingProgress.estimatedTimeRemaining)}</span>
<span>{t('onboarding:steps.ml_training.estimated_time_remaining', 'Tiempo restante estimado: {{time}}', { time: formatTime(trainingProgress.estimatedTimeRemaining) })}</span>
)}
</div>
</div>
@@ -293,6 +322,35 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
</div>
</div>
{/* Skip to Dashboard Option - Show after 2 minutes */}
{showSkipOption && isTraining && trainingProgress?.stage !== 'completed' && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
{t('onboarding:steps.ml_training.skip_to_dashboard.title', '¿Toma demasiado tiempo?')}
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
{t('onboarding:steps.ml_training.skip_to_dashboard.info', 'El entrenamiento está tardando más de lo esperado. No te preocupes, puedes explorar tu dashboard mientras el modelo termina de entrenarse en segundo plano.')}
</p>
<Button
onClick={handleSkipToDashboard}
variant="secondary"
size="sm"
>
{t('onboarding:steps.ml_training.skip_to_dashboard.button', 'Ir al Dashboard')}
</Button>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-2">
{t('onboarding:steps.ml_training.skip_to_dashboard.training_continues', 'El entrenamiento continúa en segundo plano')}
</p>
</div>
</div>
</div>
)}
{/* Training Info */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<h4 className="font-medium mb-2">¿Qué sucede durante el entrenamiento?</h4>

View File

@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Button } from '../../../ui/Button';
import { Input } from '../../../ui/Input';
import { useRegisterBakery } from '../../../../api/hooks/tenant';
import { BakeryRegistration } from '../../../../api/types/tenant';
import { nominatimService, NominatimResult } from '../../../../api/services/nominatim';
import { debounce } from 'lodash';
interface RegisterTenantStepProps {
onNext: () => void;
@@ -27,14 +29,51 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [addressSuggestions, setAddressSuggestions] = useState<NominatimResult[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const registerBakery = useRegisterBakery();
// Debounced address search
const searchAddress = useCallback(
debounce(async (query: string) => {
if (query.length < 3) {
setAddressSuggestions([]);
return;
}
setIsSearching(true);
try {
const results = await nominatimService.searchAddress(query);
setAddressSuggestions(results);
setShowSuggestions(true);
} catch (error) {
console.error('Address search failed:', error);
} finally {
setIsSearching(false);
}
}, 500),
[]
);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
searchAddress.cancel();
};
}, [searchAddress]);
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
// Trigger address search when address field changes
if (field === 'address') {
searchAddress(value);
}
if (errors[field]) {
setErrors(prev => ({
...prev,
@@ -43,6 +82,20 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
}
};
const handleAddressSelect = (result: NominatimResult) => {
const parsed = nominatimService.parseAddress(result);
setFormData(prev => ({
...prev,
address: parsed.street,
city: parsed.city,
postal_code: parsed.postalCode,
}));
setShowSuggestions(false);
setAddressSuggestions([]);
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
@@ -121,15 +174,43 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
isRequired
/>
<div className="md:col-span-2">
<div className="md:col-span-2 relative">
<Input
label="Dirección"
placeholder="Calle Principal 123, Ciudad, Provincia"
placeholder="Calle Principal 123, Madrid"
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
onFocus={() => {
if (addressSuggestions.length > 0) {
setShowSuggestions(true);
}
}}
onBlur={() => {
setTimeout(() => setShowSuggestions(false), 200);
}}
error={errors.address}
isRequired
/>
{isSearching && (
<div className="absolute right-3 top-10 text-gray-400">
Buscando...
</div>
)}
{showSuggestions && addressSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{addressSuggestions.map((result) => (
<div
key={result.place_id}
className="px-4 py-3 hover:bg-gray-100 cursor-pointer border-b border-gray-100 last:border-b-0"
onClick={() => handleAddressSelect(result)}
>
<div className="text-sm font-medium text-gray-900">
{nominatimService.formatAddress(result)}
</div>
</div>
))}
</div>
)}
</div>
<Input

View File

@@ -0,0 +1,369 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Check, Star, ArrowRight, Package, TrendingUp, Settings, Loader } from 'lucide-react';
import { Button } from '../ui';
import {
subscriptionService,
type PlanMetadata,
type SubscriptionTier,
SUBSCRIPTION_TIERS
} from '../../api';
type BillingCycle = 'monthly' | 'yearly';
export const PricingSection: React.FC = () => {
const [plans, setPlans] = useState<Record<SubscriptionTier, PlanMetadata> | null>(null);
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadPlans();
}, []);
const loadPlans = async () => {
try {
setLoading(true);
setError(null);
const availablePlans = await subscriptionService.fetchAvailablePlans();
setPlans(availablePlans.plans);
} catch (err) {
console.error('Failed to load plans:', err);
setError('No se pudieron cargar los planes. Por favor, intenta nuevamente.');
} finally {
setLoading(false);
}
};
const getPrice = (plan: PlanMetadata) => {
return billingCycle === 'monthly' ? plan.monthly_price : plan.yearly_price;
};
const getSavings = (plan: PlanMetadata) => {
if (billingCycle === 'yearly') {
return subscriptionService.calculateYearlySavings(
plan.monthly_price,
plan.yearly_price
);
}
return null;
};
const getPlanIcon = (tier: SubscriptionTier) => {
switch (tier) {
case SUBSCRIPTION_TIERS.STARTER:
return <Package className="w-6 h-6" />;
case SUBSCRIPTION_TIERS.PROFESSIONAL:
return <TrendingUp className="w-6 h-6" />;
case SUBSCRIPTION_TIERS.ENTERPRISE:
return <Settings className="w-6 h-6" />;
default:
return <Package className="w-6 h-6" />;
}
};
const formatFeatureName = (feature: string): string => {
const featureNames: Record<string, string> = {
'inventory_management': 'Gestión de inventario',
'sales_tracking': 'Seguimiento de ventas',
'basic_recipes': 'Recetas básicas',
'production_planning': 'Planificación de producción',
'basic_reporting': 'Informes básicos',
'mobile_app_access': 'Acceso desde app móvil',
'email_support': 'Soporte por email',
'easy_step_by_step_onboarding': 'Onboarding guiado paso a paso',
'basic_forecasting': 'Pronósticos básicos',
'demand_prediction': 'Predicción de demanda IA',
'waste_tracking': 'Seguimiento de desperdicios',
'order_management': 'Gestión de pedidos',
'customer_management': 'Gestión de clientes',
'supplier_management': 'Gestión de proveedores',
'batch_tracking': 'Trazabilidad de lotes',
'expiry_alerts': 'Alertas de caducidad',
'advanced_analytics': 'Analíticas avanzadas',
'custom_reports': 'Informes personalizados',
'sales_analytics': 'Análisis de ventas',
'supplier_performance': 'Rendimiento de proveedores',
'waste_analysis': 'Análisis de desperdicios',
'profitability_analysis': 'Análisis de rentabilidad',
'weather_data_integration': 'Integración datos meteorológicos',
'traffic_data_integration': 'Integración datos de tráfico',
'multi_location_support': 'Soporte multi-ubicación',
'location_comparison': 'Comparación entre ubicaciones',
'inventory_transfer': 'Transferencias de inventario',
'batch_scaling': 'Escalado de lotes',
'recipe_feasibility_check': 'Verificación de factibilidad',
'seasonal_patterns': 'Patrones estacionales',
'longer_forecast_horizon': 'Horizonte de pronóstico extendido',
'pos_integration': 'Integración POS',
'accounting_export': 'Exportación contable',
'basic_api_access': 'Acceso API básico',
'priority_email_support': 'Soporte prioritario por email',
'phone_support': 'Soporte telefónico',
'scenario_modeling': 'Modelado de escenarios',
'what_if_analysis': 'Análisis what-if',
'risk_assessment': 'Evaluación de riesgos',
'full_api_access': 'Acceso completo API',
'unlimited_webhooks': 'Webhooks ilimitados',
'erp_integration': 'Integración ERP',
'custom_integrations': 'Integraciones personalizadas',
'sso_saml': 'SSO/SAML',
'advanced_permissions': 'Permisos avanzados',
'audit_logs_export': 'Exportación de logs de auditoría',
'compliance_reports': 'Informes de cumplimiento',
'dedicated_account_manager': 'Gestor de cuenta dedicado',
'priority_support': 'Soporte prioritario',
'support_24_7': 'Soporte 24/7',
'custom_training': 'Formación personalizada'
};
return featureNames[feature] || feature.replace(/_/g, ' ');
};
if (loading) {
return (
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-center items-center py-20">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Cargando planes...</span>
</div>
</div>
</section>
);
}
if (error || !plans) {
return (
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center py-20">
<p className="text-[var(--color-error)]">{error}</p>
<Button onClick={loadPlans} className="mt-4">Reintentar</Button>
</div>
</div>
</section>
);
}
return (
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center">
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
Planes que se Adaptan a tu Negocio
</h2>
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
Sin costos ocultos, sin compromisos largos. Comienza gratis y escala según crezcas.
</p>
{/* Billing Cycle Toggle */}
<div className="mt-8 inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
<button
onClick={() => setBillingCycle('monthly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all ${
billingCycle === 'monthly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Mensual
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={`px-6 py-2 rounded-md text-sm font-semibold transition-all flex items-center gap-2 ${
billingCycle === 'yearly'
? 'bg-[var(--color-primary)] text-white shadow-md'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Anual
<span className="text-xs font-bold text-green-600 dark:text-green-400">
Ahorra 17%
</span>
</button>
</div>
</div>
{/* Plans Grid */}
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
{Object.entries(plans).map(([tier, plan]) => {
const price = getPrice(plan);
const savings = getSavings(plan);
const isPopular = plan.popular;
const tierKey = tier as SubscriptionTier;
return (
<div
key={tier}
className={`
group relative rounded-3xl p-8 transition-all duration-300
${isPopular
? 'bg-gradient-to-br from-[var(--color-primary)] via-[var(--color-primary)] to-[var(--color-primary-dark)] shadow-2xl transform scale-105 z-10'
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-primary)] hover:border-[var(--color-primary)]/30 hover:shadow-xl hover:-translate-y-1'
}
`}
>
{/* Popular Badge */}
{isPopular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<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 flex items-center gap-1">
<Star className="w-4 h-4 fill-current" />
Más Popular
</div>
</div>
)}
{/* Icon */}
<div className="absolute top-6 right-6">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
isPopular
? 'bg-white/10 text-white'
: 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
}`}>
{getPlanIcon(tierKey)}
</div>
</div>
{/* Header */}
<div className={`mb-6 ${isPopular ? 'pt-4' : ''}`}>
<h3 className={`text-2xl font-bold ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{plan.name}
</h3>
<p className={`mt-3 leading-relaxed ${isPopular ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
{plan.tagline}
</p>
</div>
{/* Pricing */}
<div className="mb-8">
<div className="flex items-baseline">
<span className={`text-5xl font-bold ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{subscriptionService.formatPrice(price)}
</span>
<span className={`ml-2 text-lg ${isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
/{billingCycle === 'monthly' ? 'mes' : 'año'}
</span>
</div>
{/* Savings Badge */}
{savings && (
<div className={`mt-2 px-3 py-1 text-sm font-medium rounded-full inline-block ${
isPopular ? 'bg-white/20 text-white' : 'bg-green-500/10 text-green-600 dark:text-green-400'
}`}>
Ahorra {subscriptionService.formatPrice(savings.savingsAmount)}/año
</div>
)}
{/* Trial Badge */}
{!savings && (
<div className={`mt-2 px-3 py-1 text-sm font-medium rounded-full inline-block ${
isPopular ? 'bg-white/20 text-white' : 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
}`}>
{plan.trial_days} días gratis
</div>
)}
</div>
{/* Key Limits */}
<div className={`mb-6 p-4 rounded-lg ${
isPopular ? 'bg-white/10' : 'bg-[var(--bg-primary)]'
}`}>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Usuarios:</span>
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{plan.limits.users || 'Ilimitado'}
</span>
</div>
<div>
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Ubicaciones:</span>
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{plan.limits.locations || 'Ilimitado'}
</span>
</div>
<div>
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Productos:</span>
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{plan.limits.products || 'Ilimitado'}
</span>
</div>
<div>
<span className={isPopular ? 'text-white/80' : 'text-[var(--text-secondary)]'}>Pronósticos/día:</span>
<span className={`font-semibold ml-2 ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{plan.limits.forecasts_per_day || 'Ilimitado'}
</span>
</div>
</div>
</div>
{/* Features List (first 8) */}
<div className={`space-y-3 mb-8 ${isPopular ? 'max-h-80' : 'max-h-72'} overflow-y-auto pr-2 scrollbar-thin`}>
{plan.features.slice(0, 8).map((feature) => (
<div key={feature} className="flex items-start">
<div className="flex-shrink-0 mt-1">
<div className={`w-5 h-5 rounded-full flex items-center justify-center ${
isPopular
? 'bg-white'
: 'bg-[var(--color-success)]'
}`}>
<Check className={`w-3 h-3 ${isPopular ? 'text-[var(--color-primary)]' : 'text-white'}`} />
</div>
</div>
<span className={`ml-3 text-sm font-medium ${isPopular ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{formatFeatureName(feature)}
</span>
</div>
))}
{plan.features.length > 8 && (
<p className={`text-sm italic ${isPopular ? 'text-white/70' : 'text-[var(--text-secondary)]'}`}>
Y {plan.features.length - 8} características más...
</p>
)}
</div>
{/* Support */}
<div className={`mb-6 text-sm text-center border-t pt-4 ${
isPopular ? 'text-white/80 border-white/20' : 'text-[var(--text-secondary)] border-[var(--border-primary)]'
}`}>
{plan.support}
</div>
{/* CTA Button */}
<Link to={plan.contact_sales ? '/contact' : `/register?plan=${tier}`}>
<Button
className={`w-full py-4 text-base font-semibold transition-all duration-200 ${
isPopular
? 'bg-white text-[var(--color-primary)] hover:bg-gray-100 shadow-lg hover:shadow-xl'
: 'border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white'
}`}
variant={isPopular ? 'primary' : 'outline'}
>
{plan.contact_sales ? 'Contactar Ventas' : 'Comenzar Prueba Gratuita'}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
<p className={`text-xs text-center mt-3 ${isPopular ? 'text-white/70' : 'text-[var(--text-secondary)]'}`}>
{plan.trial_days} días gratis Sin tarjeta requerida
</p>
</div>
);
})}
</div>
{/* Feature Comparison Link */}
<div className="text-center mt-12">
<Link
to="/plans/compare"
className="text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] font-semibold inline-flex items-center gap-2"
>
Ver comparación completa de características
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
</section>
);
};

View File

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