Update landing page

This commit is contained in:
Urtzi Alfaro
2025-10-18 16:03:23 +02:00
parent 312e36c893
commit 62971c07d7
21 changed files with 1760 additions and 884 deletions

View File

@@ -17,13 +17,14 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@600;700&display=swap" rel="stylesheet" />
<!-- Runtime configuration - MUST load before app code (Kubernetes deployment) -->
<script src="/runtime-config.js"></script>
<title>Bakery AI - Gestión Inteligente para Panaderías</title>
</head>
<body>
<div id="root"></div>
<!-- Runtime configuration - loaded by Kubernetes deployment -->
<script src="/runtime-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -10,6 +10,8 @@ interface PaymentFormProps {
className?: string;
bypassPayment?: boolean;
onBypassToggle?: () => void;
userName?: string;
userEmail?: string;
}
const PaymentForm: React.FC<PaymentFormProps> = ({
@@ -17,7 +19,9 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
onPaymentError,
className = '',
bypassPayment = false,
onBypassToggle
onBypassToggle,
userName = '',
userEmail = ''
}) => {
const { t } = useTranslation();
const stripe = useStripe();
@@ -26,8 +30,8 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
const [error, setError] = useState<string | null>(null);
const [cardComplete, setCardComplete] = useState(false);
const [billingDetails, setBillingDetails] = useState({
name: '',
email: '',
name: userName,
email: userEmail,
address: {
line1: '',
city: '',

View File

@@ -1,76 +0,0 @@
/**
* Pilot Banner Component
* Displays when pilot=true URL parameter is detected
* Shows 3-month free trial information and PILOT2025 coupon code
*/
import React from 'react';
import { Star, Check, Award } from 'lucide-react';
import { Card } from '../../ui';
interface PilotBannerProps {
couponCode: string;
trialMonths: number;
className?: string;
}
export const PilotBanner: React.FC<PilotBannerProps> = ({
couponCode,
trialMonths,
className = ''
}) => {
return (
<Card className={`p-6 border-2 border-amber-500 dark:border-amber-600 bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 ${className}`}>
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-lg">
<Award className="w-6 h-6 text-white" />
</div>
{/* Content */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Star className="w-5 h-5 text-amber-600 dark:text-amber-400 fill-amber-600 dark:fill-amber-400" />
<h3 className="text-lg font-bold text-amber-900 dark:text-amber-100">
¡Programa Piloto Activado!
</h3>
<Star className="w-5 h-5 text-amber-600 dark:text-amber-400 fill-amber-600 dark:fill-amber-400" />
</div>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-3">
Has sido seleccionado para nuestro programa piloto exclusivo.
Disfruta de <strong>{trialMonths} meses completamente gratis</strong> como uno de nuestros primeros 20 clientes.
</p>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2 bg-white/60 dark:bg-gray-800/60 px-3 py-1.5 rounded-lg border border-amber-300 dark:border-amber-700">
<Check className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Cupón: <span className="font-mono font-bold text-amber-700 dark:text-amber-300">{couponCode}</span>
</span>
</div>
<div className="flex items-center gap-2 bg-white/60 dark:bg-gray-800/60 px-3 py-1.5 rounded-lg border border-amber-300 dark:border-amber-700">
<Check className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{trialMonths} meses gratis
</span>
</div>
<div className="flex items-center gap-2 bg-white/60 dark:bg-gray-800/60 px-3 py-1.5 rounded-lg border border-amber-300 dark:border-amber-700">
<Check className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
20% descuento de por vida
</span>
</div>
</div>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-3 italic">
El cupón se aplicará automáticamente durante el registro. Tarjeta requerida para validación, sin cargo inmediato.
</p>
</div>
</div>
</Card>
);
};
export default PilotBanner;

View File

@@ -1,19 +1,30 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { Button, Input, Card } from '../../ui';
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
import { useToast } from '../../../hooks/ui/useToast';
import { SubscriptionSelection } from './SubscriptionSelection';
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
import PaymentForm from './PaymentForm';
import PilotBanner from './PilotBanner';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import { CheckCircle } from 'lucide-react';
import { CheckCircle, Clock } from 'lucide-react';
import { usePilotDetection } from '../../../hooks/usePilotDetection';
import { subscriptionService } from '../../../api';
// Initialize Stripe - In production, use environment variable
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_test_51234567890123456789012345678901234567890123456789012345678901234567890123456789012345');
// Helper to get Stripe key from runtime config or build-time env
const getStripeKey = (): string => {
// Try runtime config first (Kubernetes/Docker)
if (typeof window !== 'undefined' && window.__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) {
return window.__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY;
}
// Fallback to build-time env (local development)
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_test_51234567890123456789012345678901234567890123456789012345678901234567890123456789012345';
};
// Initialize Stripe with runtime or build-time key
const stripePromise = loadStripe(getStripeKey());
interface RegisterFormProps {
onSuccess?: () => void;
@@ -62,11 +73,17 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
// Detect pilot program participation
const { isPilot, couponCode, trialMonths } = usePilotDetection();
// Read URL parameters for plan persistence
const [searchParams] = useSearchParams();
const preSelectedPlan = searchParams.get('plan') as string | null; // starter | professional | enterprise
const urlPilotParam = searchParams.get('pilot') === 'true';
// Multi-step form state
const [currentStep, setCurrentStep] = useState<RegistrationStep>('basic_info');
const [selectedPlan, setSelectedPlan] = useState<string>('starter');
const [useTrial, setUseTrial] = useState<boolean>(isPilot); // Auto-enable trial for pilot customers
const [selectedPlan, setSelectedPlan] = useState<string>(preSelectedPlan || 'starter');
const [useTrial, setUseTrial] = useState<boolean>(isPilot || urlPilotParam); // Auto-enable trial for pilot customers
const [bypassPayment, setBypassPayment] = useState<boolean>(false);
const [selectedPlanMetadata, setSelectedPlanMetadata] = useState<any>(null);
// Helper function to determine password match status
const getPasswordMatchStatus = () => {
@@ -77,6 +94,58 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const passwordMatchStatus = getPasswordMatchStatus();
// Load plan metadata when plan changes
useEffect(() => {
const loadPlanMetadata = async () => {
try {
const plans = await subscriptionService.fetchAvailablePlans();
const planData = plans.plans[selectedPlan as keyof typeof plans.plans];
setSelectedPlanMetadata(planData);
} catch (err) {
console.error('Failed to load plan metadata:', err);
}
};
loadPlanMetadata();
}, [selectedPlan]);
// Save form progress to localStorage
useEffect(() => {
const formState = {
formData,
selectedPlan,
useTrial,
currentStep,
timestamp: Date.now(),
};
localStorage.setItem('registration_progress', JSON.stringify(formState));
}, [formData, selectedPlan, useTrial, currentStep]);
// Recover form state on mount (if less than 24 hours old)
useEffect(() => {
// Only recover if not coming from a direct link with plan pre-selected
if (preSelectedPlan) return;
const saved = localStorage.getItem('registration_progress');
if (saved) {
try {
const state = JSON.parse(saved);
const age = Date.now() - state.timestamp;
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (age < maxAge) {
// Optionally restore state (for now, just log it exists)
console.log('Found saved registration progress');
} else {
// Clear old state
localStorage.removeItem('registration_progress');
}
} catch (err) {
console.error('Failed to parse saved registration state:', err);
localStorage.removeItem('registration_progress');
}
}
}, [preSelectedPlan]);
const validateForm = (): boolean => {
const newErrors: Partial<SimpleUserRegistration> = {};
@@ -120,17 +189,25 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
if (!validateForm()) {
return;
}
setCurrentStep('subscription');
// OPTION A: Skip subscription step if plan was pre-selected from pricing page
if (preSelectedPlan) {
setCurrentStep('payment');
} else {
setCurrentStep('subscription');
}
} else if (currentStep === 'subscription') {
setCurrentStep('payment');
}
};
const handlePreviousStep = () => {
if (currentStep === 'subscription') {
if (currentStep === 'payment' && preSelectedPlan) {
// Go back to basic_info (skip subscription step)
setCurrentStep('basic_info');
} else if (currentStep === 'payment') {
setCurrentStep('subscription');
} else if (currentStep === 'subscription') {
setCurrentStep('basic_info');
}
};
@@ -190,11 +267,17 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
// Render step indicator
const renderStepIndicator = () => {
const steps = [
{ key: 'basic_info', label: 'Información', number: 1 },
{ key: 'subscription', label: 'Plan', number: 2 },
{ key: 'payment', label: 'Pago', number: 3 }
];
// Show 2 steps if plan is pre-selected, 3 steps otherwise
const steps = preSelectedPlan
? [
{ key: 'basic_info', label: 'Información', number: 1, time: '2 min' },
{ key: 'payment', label: 'Pago', number: 2, time: '2 min' }
]
: [
{ key: 'basic_info', label: 'Información', number: 1, time: '2 min' },
{ key: 'subscription', label: 'Plan', number: 2, time: '1 min' },
{ key: 'payment', label: 'Pago', number: 3, time: '2 min' }
];
const getStepIndex = (step: RegistrationStep) => {
return steps.findIndex(s => s.key === step);
@@ -221,11 +304,19 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
step.number
)}
</div>
<span className={`mt-1 sm:mt-2 text-[10px] sm:text-xs font-medium hidden sm:block ${
index <= currentIndex ? 'text-text-primary' : 'text-text-secondary'
}`}>
{step.label}
</span>
<div className="flex flex-col items-center mt-1 sm:mt-2">
<span className={`text-[10px] sm:text-xs font-medium block ${
index <= currentIndex ? 'text-text-primary' : 'text-text-secondary'
}`}>
{step.label}
</span>
<span className={`text-[8px] sm:text-[10px] flex items-center gap-0.5 ${
index === currentIndex ? 'text-text-tertiary' : 'hidden sm:flex text-text-tertiary'
}`}>
<Clock className="w-2 h-2 sm:w-3 sm:h-3" />
{step.time}
</span>
</div>
</div>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 sm:w-16 mx-2 sm:mx-4 transition-all duration-200 ${
@@ -497,16 +588,13 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
</p>
</div>
{isPilot && couponCode && (
<PilotBanner couponCode={couponCode} trialMonths={trialMonths} />
)}
<SubscriptionSelection
<SubscriptionPricingCards
mode="selection"
selectedPlan={selectedPlan}
onPlanSelect={setSelectedPlan}
showTrialOption={!isPilot}
onTrialSelect={setUseTrial}
trialSelected={useTrial}
showPilotBanner={isPilot}
pilotCouponCode={couponCode}
pilotTrialMonths={trialMonths}
/>
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-4 pt-2 border-t border-border-primary">
@@ -544,8 +632,52 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
</p>
</div>
{/* Plan Summary Card */}
{selectedPlanMetadata && (
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border-2 border-blue-200 dark:border-blue-800">
<h3 className="text-lg font-bold text-text-primary mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-color-primary" />
Resumen de tu Plan
</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-text-secondary">Plan seleccionado:</span>
<span className="font-bold text-color-primary text-lg">{selectedPlanMetadata.name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-text-secondary">Precio mensual:</span>
<span className="font-semibold text-text-primary">
{subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)}/mes
</span>
</div>
{useTrial && (
<div className="flex justify-between items-center pt-3 border-t border-blue-200 dark:border-blue-800">
<span className="text-green-700 dark:text-green-400 font-medium">Período de prueba:</span>
<span className="font-bold text-green-700 dark:text-green-400">
{isPilot ? `${trialMonths} meses GRATIS` : '14 días gratis'}
</span>
</div>
)}
<div className="pt-3 border-t border-blue-200 dark:border-blue-800">
<div className="flex justify-between items-center text-sm">
<span className="text-text-tertiary">Total hoy:</span>
<span className="font-bold text-xl text-color-success">0.00</span>
</div>
<p className="text-xs text-text-tertiary mt-2 text-center">
{useTrial
? `Se te cobrará ${subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)} después del período de prueba`
: 'Tarjeta requerida para validación'
}
</p>
</div>
</div>
</Card>
)}
<Elements stripe={stripePromise}>
<PaymentForm
userName={formData.full_name}
userEmail={formData.email}
onPaymentSuccess={handlePaymentSuccess}
onPaymentError={handlePaymentError}
bypassPayment={bypassPayment}

View File

@@ -1,371 +0,0 @@
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, Zap } from 'lucide-react';
import { subscriptionService, type AvailablePlans, type PlanMetadata, SUBSCRIPTION_TIERS } from '../../../api';
interface SubscriptionSelectionProps {
selectedPlan: string;
onPlanSelect: (planKey: string) => void;
showTrialOption?: boolean;
onTrialSelect?: (useTrial: boolean) => void;
trialSelected?: boolean;
className?: string;
}
export const SubscriptionSelection: React.FC<SubscriptionSelectionProps> = ({
selectedPlan,
onPlanSelect,
showTrialOption = false,
onTrialSelect,
trialSelected = false,
className = ''
}) => {
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 {
setLoading(true);
setError(null);
const plans = await subscriptionService.fetchAvailablePlans();
setAvailablePlans(plans);
} catch (err) {
console.error('Error fetching subscription plans:', err);
setError('No se pudieron cargar los planes. Por favor, intenta de nuevo.');
} finally {
setLoading(false);
}
};
fetchPlans();
}, []);
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>
</div>
);
}
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 90 for pilot customers)
const trialDays = 90; // 3 months for pilot customers
return (
<div className={`space-y-4 ${className}`}>
{showTrialOption && (
<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/20 rounded-lg flex-shrink-0">
<Star className="w-5 h-5 text-color-primary" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-text-primary text-base">
{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 - tarjeta requerida para validación`)}
</p>
</div>
</div>
<Button
variant={trialSelected ? "primary" : "outline"}
size="md"
onClick={handleTrialToggle}
className="w-full sm:w-auto flex-shrink-0 min-w-[100px]"
>
{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>
)}
<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 ring-2 ring-color-primary/20'
: 'border-border-primary bg-bg-primary hover:border-color-primary/40 hover:shadow-md'
} ${metadata.popular ? 'pt-8' : ''}`}
onClick={() => onPlanSelect(planKey)}
>
{/* Popular Badge */}
{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" />
{t('auth:subscription.popular', 'Más Popular')}
</Badge>
</div>
)}
{/* Card Content */}
<div className="space-y-6">
{/* 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">
<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" />
3 meses 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(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">{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 */}
<div className="hidden sm:flex flex-shrink-0">
<Button
variant={isSelected ? "primary" : "outline"}
className="min-w-[140px]"
size="lg"
onClick={(e) => {
e.stopPropagation();
onPlanSelect(planKey);
}}
>
{isSelected ? (
<div className="flex items-center justify-center gap-2">
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Seleccionado</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<span className="font-semibold">Elegir Plan</span>
<ArrowRight className="w-4 h-4" />
</div>
)}
</Button>
</div>
</div>
{/* Body Section: Limits & Features */}
<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 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="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">
<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 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>
{/* Action Button - Mobile position */}
<div className="sm:hidden pt-4 border-t border-border-primary/50">
<Button
variant={isSelected ? "primary" : "outline"}
className="w-full"
size="lg"
onClick={(e) => {
e.stopPropagation();
onPlanSelect(planKey);
}}
>
{isSelected ? (
<div className="flex items-center justify-center gap-2">
<CheckCircle className="w-5 h-5" />
<span className="font-semibold">Seleccionado</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<span className="font-semibold">Elegir Plan</span>
<ArrowRight className="w-4 h-4" />
</div>
)}
</Button>
</div>
</div>
</Card>
);
})}
</div>
</div>
);
};

View File

@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button, ThemeToggle } from '../../ui';
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
import { getRegisterUrl, getLoginUrl } from '../../../utils/navigation';
export interface PublicHeaderProps {
className?: string;
@@ -176,7 +177,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
{/* Authentication buttons - Enhanced */}
{showAuthButtons && (
<div className="flex items-center gap-2 lg:gap-3">
<Link to="/login">
<Link to={getLoginUrl()}>
<Button
variant="ghost"
size="md"
@@ -185,7 +186,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
{t('common:header.login')}
</Button>
</Link>
<Link to="/register">
<Link to={getRegisterUrl()}>
<Button
size="md"
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg hover:shadow-xl transition-all duration-200 px-6"
@@ -248,7 +249,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
{/* Mobile auth buttons */}
{showAuthButtons && (
<div className="flex flex-col gap-3 pt-4 sm:hidden">
<Link to="/login">
<Link to={getLoginUrl()}>
<Button
variant="ghost"
size="md"
@@ -257,7 +258,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
{t('common:header.login')}
</Button>
</Link>
<Link to="/register">
<Link to={getRegisterUrl()}>
<Button
size="md"
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg"

View File

@@ -1,357 +1,24 @@
import React, { useEffect, useState } from 'react';
import React 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';
import { ArrowRight } from 'lucide-react';
import { SubscriptionPricingCards } from './SubscriptionPricingCards';
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">
<div className="text-center mb-8">
<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)]'
}`}>
3 meses 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)]'}`}>
3 meses gratis Tarjeta requerida para validación
</p>
</div>
);
})}
</div>
{/* Pricing Cards */}
<SubscriptionPricingCards mode="landing" />
{/* Feature Comparison Link */}
<div className="text-center mt-12">

View File

@@ -0,0 +1,430 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Check, Star, ArrowRight, Package, TrendingUp, Settings, Loader, Users, MapPin, CheckCircle, Zap } from 'lucide-react';
import { Button, Card, Badge } from '../ui';
import {
subscriptionService,
type PlanMetadata,
type SubscriptionTier,
SUBSCRIPTION_TIERS
} from '../../api';
import { getRegisterUrl } from '../../utils/navigation';
type BillingCycle = 'monthly' | 'yearly';
type DisplayMode = 'landing' | 'selection';
interface SubscriptionPricingCardsProps {
mode?: DisplayMode;
selectedPlan?: string;
onPlanSelect?: (planKey: string) => void;
showPilotBanner?: boolean;
pilotCouponCode?: string;
pilotTrialMonths?: number;
className?: string;
}
export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> = ({
mode = 'landing',
selectedPlan,
onPlanSelect,
showPilotBanner = false,
pilotCouponCode,
pilotTrialMonths = 3,
className = ''
}) => {
const { t } = useTranslation();
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, ' ');
};
const handlePlanAction = (tier: string, plan: PlanMetadata) => {
if (mode === 'selection' && onPlanSelect) {
onPlanSelect(tier);
}
};
if (loading) {
return (
<div className={`flex justify-center items-center py-20 ${className}`}>
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Cargando planes...</span>
</div>
);
}
if (error || !plans) {
return (
<div className={`text-center py-20 ${className}`}>
<p className="text-[var(--color-error)] mb-4">{error}</p>
<Button onClick={loadPlans}>Reintentar</Button>
</div>
);
}
return (
<div className={className}>
{/* Pilot Program Banner */}
{showPilotBanner && pilotCouponCode && mode === 'selection' && (
<Card className="p-6 mb-6 bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500">
<div className="flex items-center gap-4">
<div className="flex-shrink-0">
<div className="w-14 h-14 bg-gradient-to-br from-amber-500 to-orange-500 rounded-full flex items-center justify-center shadow-lg">
<Star className="w-7 h-7 text-white fill-white" />
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-amber-900 dark:text-amber-100 mb-1">
Programa Piloto Activo
</h3>
<p className="text-sm text-amber-800 dark:text-amber-200">
Como participante del programa piloto, obtienes <strong>{pilotTrialMonths} meses completamente gratis</strong> en el plan que elijas,
más un <strong>20% de descuento de por vida</strong> si decides continuar.
</p>
</div>
</div>
</Card>
)}
{/* Billing Cycle Toggle */}
<div className="flex justify-center mb-8">
<div className="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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Object.entries(plans).map(([tier, plan]) => {
const price = getPrice(plan);
const savings = getSavings(plan);
const isPopular = plan.popular;
const tierKey = tier as SubscriptionTier;
const isSelected = mode === 'selection' && selectedPlan === tier;
const CardWrapper = mode === 'landing' ? Link : 'div';
const cardProps = mode === 'landing'
? { to: plan.contact_sales ? '/contact' : getRegisterUrl(tier) }
: { onClick: () => handlePlanAction(tier, plan) };
return (
<CardWrapper
key={tier}
{...cardProps}
className={`
group relative rounded-3xl p-8 transition-all duration-300 block
${mode === 'selection' ? 'cursor-pointer' : ''}
${isSelected
? 'border-2 border-[var(--color-primary)] bg-gradient-to-br from-[var(--color-primary)]/10 via-[var(--color-primary)]/5 to-transparent shadow-2xl ring-4 ring-[var(--color-primary)]/30 scale-[1.02]'
: 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'
: isSelected
? 'bg-[var(--color-primary)]/20 text-[var(--color-primary)]'
: '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' : isSelected ? 'text-[var(--color-primary)]' : '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' : isSelected ? 'text-[var(--color-primary)]' : '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)]'
}`}>
3 meses gratis
</div>
)}
</div>
{/* Key Limits */}
<div className={`mb-6 p-4 rounded-lg ${
isPopular ? 'bg-white/10' : isSelected ? 'bg-[var(--color-primary)]/5' : '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' : isSelected ? 'text-[var(--color-primary)]' : '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' : isSelected ? 'text-[var(--color-primary)]' : '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' : isSelected ? 'text-[var(--color-primary)]' : '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' : isSelected ? 'text-[var(--color-primary)]' : 'text-[var(--text-primary)]'}`}>
{plan.limits.forecasts_per_day || 'Ilimitado'}
</span>
</div>
</div>
</div>
{/* Features List */}
<div className={`space-y-3 mb-8 max-h-80 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 */}
{mode === 'landing' ? (
<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>
) : (
<Button
className={`w-full py-4 text-base font-semibold transition-all duration-200 ${
isSelected
? 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-dark)]'
: isPopular
? 'bg-white text-[var(--color-primary)] hover:bg-gray-100'
: 'border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white'
}`}
variant={isSelected || isPopular ? 'primary' : 'outline'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handlePlanAction(tier, plan);
}}
>
{isSelected ? (
<>
<CheckCircle className="mr-2 w-4 h-4" />
Seleccionado
</>
) : (
<>
Elegir Plan
<ArrowRight className="ml-2 w-4 h-4" />
</>
)}
</Button>
)}
<p className={`text-xs text-center mt-3 ${isPopular ? 'text-white/70' : 'text-[var(--text-secondary)]'}`}>
3 meses gratis Tarjeta requerida para validación
</p>
</CardWrapper>
);
})}
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,92 @@
/**
* Pilot Program Configuration
*
* Centralized configuration for pilot mode features.
*
* Works in two modes:
* 1. Kubernetes/Docker: Reads from window.__RUNTIME_CONFIG__ (injected at container startup)
* 2. Local Development: Reads from import.meta.env (build-time variables from .env)
*/
/**
* Helper function to get environment variable value
* Tries runtime config first (Kubernetes), falls back to build-time (local dev)
*/
const getEnvVar = (key: string): string | undefined => {
// Try runtime config first (Kubernetes/Docker environment)
if (typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__) {
const value = (window as any).__RUNTIME_CONFIG__[key];
if (value !== undefined) {
return value;
}
}
// Fallback to build-time environment variables (local development)
return import.meta.env[key];
};
/**
* Create pilot config with getter functions to ensure we always read fresh values
* This is important because runtime-config.js might load after this module
*/
const createPilotConfig = () => {
return {
/**
* Master switch for pilot mode
* When false, all pilot features are disabled globally
*/
get enabled(): boolean {
const value = getEnvVar('VITE_PILOT_MODE_ENABLED');
return value === 'true';
},
/**
* Coupon code for pilot participants
*/
get couponCode(): string {
return getEnvVar('VITE_PILOT_COUPON_CODE') || 'PILOT2025';
},
/**
* Trial period in months for pilot participants
*/
get trialMonths(): number {
return parseInt(getEnvVar('VITE_PILOT_TRIAL_MONTHS') || '3');
},
/**
* Trial period in days (calculated from months)
*/
get trialDays(): number {
return this.trialMonths * 30;
},
/**
* Lifetime discount percentage for pilot participants
*/
lifetimeDiscount: 20,
};
};
export const PILOT_CONFIG = createPilotConfig();
// Debug logging
console.log('🔧 Pilot Config Loading:', {
source: typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__ ? 'runtime' : 'build-time',
raw: getEnvVar('VITE_PILOT_MODE_ENABLED'),
type: typeof getEnvVar('VITE_PILOT_MODE_ENABLED'),
enabled: PILOT_CONFIG.enabled,
runtimeConfigExists: typeof window !== 'undefined' && !!(window as any).__RUNTIME_CONFIG__,
runtimeConfigKeys: typeof window !== 'undefined' && (window as any).__RUNTIME_CONFIG__
? Object.keys((window as any).__RUNTIME_CONFIG__)
: []
});
console.log('✅ Pilot Config:', {
enabled: PILOT_CONFIG.enabled,
couponCode: PILOT_CONFIG.couponCode,
trialMonths: PILOT_CONFIG.trialMonths,
trialDays: PILOT_CONFIG.trialDays
});
export default PILOT_CONFIG;

View File

@@ -1,9 +1,14 @@
/**
* Custom hook to detect pilot program participation via URL parameter
* Checks for ?pilot=true in URL and provides pilot status and coupon code
* Custom hook to detect pilot program participation
*
* Checks both environment variable (VITE_PILOT_MODE_ENABLED) and URL parameter (?pilot=true)
* to determine if pilot mode is active.
*
* Priority: Environment variable OR URL parameter (either can enable pilot mode)
*/
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import PILOT_CONFIG from '../config/pilot';
interface PilotDetectionResult {
isPilot: boolean;
@@ -16,15 +21,18 @@ export const usePilotDetection = (): PilotDetectionResult => {
const location = useLocation();
const pilotInfo = useMemo(() => {
// Check URL parameter
const searchParams = new URLSearchParams(location.search);
const pilotParam = searchParams.get('pilot');
const isPilot = pilotParam === 'true';
const urlPilotParam = searchParams.get('pilot') === 'true';
// Pilot mode is active if EITHER env var is true OR URL param is true
const isPilot = PILOT_CONFIG.enabled || urlPilotParam;
return {
isPilot,
couponCode: isPilot ? 'PILOT2025' : null,
trialMonths: isPilot ? 3 : 0,
trialDays: isPilot ? 90 : 14,
couponCode: isPilot ? PILOT_CONFIG.couponCode : null,
trialMonths: isPilot ? PILOT_CONFIG.trialMonths : 0,
trialDays: isPilot ? PILOT_CONFIG.trialDays : 14,
};
}, [location.search]);

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui';
import { PublicLayout } from '../../components/layout';
import { PricingSection } from '../../components/subscription';
import { getRegisterUrl, getDemoUrl } from '../../utils/navigation';
import {
BarChart3,
TrendingUp,
@@ -116,7 +117,7 @@ const LandingPage: React.FC = () => {
</div>
<div className="mt-10 flex flex-col sm:flex-row gap-5 justify-center items-center">
<Link to="/register?pilot=true" className="w-full sm:w-auto">
<Link to={getRegisterUrl()} className="w-full sm:w-auto">
<Button
size="lg"
className="w-full sm:w-auto group relative px-10 py-5 text-lg font-bold bg-gradient-to-r from-[var(--color-primary)] to-orange-600 hover:from-[var(--color-primary-dark)] hover:to-orange-700 text-white shadow-2xl hover:shadow-3xl transform hover:scale-105 transition-all duration-300 rounded-xl overflow-hidden"
@@ -128,7 +129,7 @@ const LandingPage: React.FC = () => {
</span>
</Button>
</Link>
<Link to="/demo" className="w-full sm:w-auto">
<Link to={getDemoUrl()} className="w-full sm:w-auto">
<Button
variant="outline"
size="lg"
@@ -901,7 +902,7 @@ const LandingPage: React.FC = () => {
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-6 justify-center">
<Link to="/register?pilot=true" className="w-full sm:w-auto">
<Link to={getRegisterUrl()} className="w-full sm:w-auto">
<Button
size="lg"
className="w-full sm:w-auto group relative px-10 py-5 text-lg font-bold bg-gradient-to-r from-[var(--color-primary)] to-orange-600 hover:from-[var(--color-primary-dark)] hover:to-orange-700 text-white shadow-2xl hover:shadow-3xl transform hover:scale-105 transition-all duration-300 rounded-xl overflow-hidden"
@@ -913,7 +914,7 @@ const LandingPage: React.FC = () => {
</span>
</Button>
</Link>
<Link to="/demo" className="w-full sm:w-auto">
<Link to={getDemoUrl()} className="w-full sm:w-auto">
<Button
variant="outline"
size="lg"

View File

@@ -15,9 +15,9 @@ const RegisterPage: React.FC = () => {
};
return (
<PublicLayout
<PublicLayout
variant="centered"
maxWidth="xl"
maxWidth="7xl"
headerProps={{
showThemeToggle: true,
showAuthButtons: false,

View File

@@ -0,0 +1,71 @@
/**
* Navigation Utilities
*
* Centralized functions for generating navigation URLs with proper parameters
*/
import PILOT_CONFIG from '../config/pilot';
import type { SubscriptionTier } from '../api';
/**
* Generate register URL with proper query parameters
*
* @param planTier - Optional subscription plan tier (starter, professional, enterprise)
* @returns Register URL with appropriate query parameters
*
* @example
* // In pilot mode with plan selected
* getRegisterUrl('starter') // => '/register?pilot=true&plan=starter'
*
* // In pilot mode without plan
* getRegisterUrl() // => '/register?pilot=true'
*
* // Not in pilot mode with plan
* getRegisterUrl('professional') // => '/register?plan=professional'
*/
export const getRegisterUrl = (planTier?: SubscriptionTier | string): string => {
const params = new URLSearchParams();
// Add pilot parameter if pilot mode is enabled globally
if (PILOT_CONFIG.enabled) {
params.set('pilot', 'true');
}
// Add plan parameter if specified
if (planTier) {
params.set('plan', planTier);
}
const queryString = params.toString();
return `/register${queryString ? '?' + queryString : ''}`;
};
/**
* Generate login URL
*/
export const getLoginUrl = (): string => {
return '/login';
};
/**
* Generate demo URL
*/
export const getDemoUrl = (): string => {
return '/demo';
};
/**
* Check if current URL has pilot parameter
*/
export const isPilotUrl = (): boolean => {
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get('pilot') === 'true';
};
/**
* Get plan from current URL
*/
export const getPlanFromUrl = (): string | null => {
const searchParams = new URLSearchParams(window.location.search);
return searchParams.get('plan');
};

View File

@@ -6,9 +6,26 @@ interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_APP_VERSION: string
readonly VITE_ENVIRONMENT: string
readonly VITE_PILOT_MODE_ENABLED?: string
readonly VITE_PILOT_COUPON_CODE?: string
readonly VITE_PILOT_TRIAL_MONTHS?: string
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// Runtime configuration injected by Kubernetes at container startup
interface Window {
__RUNTIME_CONFIG__?: {
VITE_API_URL?: string;
VITE_APP_TITLE?: string;
VITE_APP_VERSION?: string;
VITE_PILOT_MODE_ENABLED?: string;
VITE_PILOT_COUPON_CODE?: string;
VITE_PILOT_TRIAL_MONTHS?: string;
VITE_STRIPE_PUBLISHABLE_KEY?: string;
};
}

View File

@@ -10,17 +10,31 @@ elif [ -z "$VITE_API_URL" ]; then
export VITE_API_URL="/api"
fi
# Default values for other environment variables
# Default values for environment variables
export VITE_APP_TITLE=${VITE_APP_TITLE:-"PanIA Dashboard"}
export VITE_APP_VERSION=${VITE_APP_VERSION:-"1.0.0"}
# Default values for pilot program configuration
export VITE_PILOT_MODE_ENABLED=${VITE_PILOT_MODE_ENABLED:-"false"}
export VITE_PILOT_COUPON_CODE=${VITE_PILOT_COUPON_CODE:-"PILOT2025"}
export VITE_PILOT_TRIAL_MONTHS=${VITE_PILOT_TRIAL_MONTHS:-"3"}
export VITE_STRIPE_PUBLISHABLE_KEY=${VITE_STRIPE_PUBLISHABLE_KEY:-"pk_test_"}
# Create a runtime configuration file that can be loaded by the frontend
cat > /usr/share/nginx/html/runtime-config.js << EOL
window.__RUNTIME_CONFIG__ = {
VITE_API_URL: '${VITE_API_URL}',
VITE_APP_TITLE: '${VITE_APP_TITLE}',
VITE_APP_VERSION: '${VITE_APP_VERSION}'
VITE_APP_VERSION: '${VITE_APP_VERSION}',
VITE_PILOT_MODE_ENABLED: '${VITE_PILOT_MODE_ENABLED}',
VITE_PILOT_COUPON_CODE: '${VITE_PILOT_COUPON_CODE}',
VITE_PILOT_TRIAL_MONTHS: '${VITE_PILOT_TRIAL_MONTHS}',
VITE_STRIPE_PUBLISHABLE_KEY: '${VITE_STRIPE_PUBLISHABLE_KEY}'
};
EOL
echo "Runtime configuration created with API URL: ${VITE_API_URL}"
echo "Runtime configuration created:"
echo " API URL: ${VITE_API_URL}"
echo " Pilot Mode: ${VITE_PILOT_MODE_ENABLED}"
echo " Pilot Coupon: ${VITE_PILOT_COUPON_CODE}"
echo " Trial Months: ${VITE_PILOT_TRIAL_MONTHS}"