From 62971c07d7a67282ee3456003501e9c45edc7aa8 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sat, 18 Oct 2025 16:03:23 +0200 Subject: [PATCH] Update landing page --- Tiltfile | 5 + frontend/index.html | 7 +- .../components/domain/auth/PaymentForm.tsx | 10 +- .../components/domain/auth/PilotBanner.tsx | 76 -- .../components/domain/auth/RegisterForm.tsx | 188 ++++- .../domain/auth/SubscriptionSelection.tsx | 371 -------- .../layout/PublicHeader/PublicHeader.tsx | 9 +- .../subscription/PricingSection.tsx | 345 +------- .../subscription/SubscriptionPricingCards.tsx | 430 ++++++++++ frontend/src/components/subscription/index.ts | 1 + frontend/src/config/pilot.ts | 92 ++ frontend/src/hooks/usePilotDetection.ts | 22 +- frontend/src/pages/public/LandingPage.tsx | 9 +- frontend/src/pages/public/RegisterPage.tsx | 4 +- frontend/src/utils/navigation.ts | 71 ++ frontend/src/vite-env.d.ts | 17 + frontend/substitute-env.sh | 20 +- infrastructure/kubernetes/base/configmap.yaml | 6 + .../tenant-seed-pilot-coupon-job.yaml | 3 +- regenerate_migrations_k8s.sh | 796 ++++++++++++++++++ services/tenant/scripts/seed_pilot_coupon.py | 162 +++- 21 files changed, 1760 insertions(+), 884 deletions(-) delete mode 100644 frontend/src/components/domain/auth/PilotBanner.tsx delete mode 100644 frontend/src/components/domain/auth/SubscriptionSelection.tsx create mode 100644 frontend/src/components/subscription/SubscriptionPricingCards.tsx create mode 100644 frontend/src/config/pilot.ts create mode 100644 frontend/src/utils/navigation.ts create mode 100755 regenerate_migrations_k8s.sh diff --git a/Tiltfile b/Tiltfile index fc66fe53..532f4f0c 100644 --- a/Tiltfile +++ b/Tiltfile @@ -225,6 +225,11 @@ k8s_resource('demo-seed-subscriptions', resource_deps=['tenant-migration', 'demo-seed-tenants'], labels=['demo-init']) +# Seed pilot coupon (runs after tenant migration) +k8s_resource('tenant-seed-pilot-coupon', + resource_deps=['tenant-migration'], + labels=['demo-init']) + # Weight 15: Seed inventory - CRITICAL: All other seeds depend on this k8s_resource('demo-seed-inventory', resource_deps=['inventory-migration', 'demo-seed-tenants'], diff --git a/frontend/index.html b/frontend/index.html index 86708766..a5f41491 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,13 +17,14 @@ - + + + + Bakery AI - Gestión Inteligente para Panaderías
- - \ No newline at end of file diff --git a/frontend/src/components/domain/auth/PaymentForm.tsx b/frontend/src/components/domain/auth/PaymentForm.tsx index 950384f5..eba86e5d 100644 --- a/frontend/src/components/domain/auth/PaymentForm.tsx +++ b/frontend/src/components/domain/auth/PaymentForm.tsx @@ -10,6 +10,8 @@ interface PaymentFormProps { className?: string; bypassPayment?: boolean; onBypassToggle?: () => void; + userName?: string; + userEmail?: string; } const PaymentForm: React.FC = ({ @@ -17,7 +19,9 @@ const PaymentForm: React.FC = ({ onPaymentError, className = '', bypassPayment = false, - onBypassToggle + onBypassToggle, + userName = '', + userEmail = '' }) => { const { t } = useTranslation(); const stripe = useStripe(); @@ -26,8 +30,8 @@ const PaymentForm: React.FC = ({ const [error, setError] = useState(null); const [cardComplete, setCardComplete] = useState(false); const [billingDetails, setBillingDetails] = useState({ - name: '', - email: '', + name: userName, + email: userEmail, address: { line1: '', city: '', diff --git a/frontend/src/components/domain/auth/PilotBanner.tsx b/frontend/src/components/domain/auth/PilotBanner.tsx deleted file mode 100644 index 78e7b50a..00000000 --- a/frontend/src/components/domain/auth/PilotBanner.tsx +++ /dev/null @@ -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 = ({ - couponCode, - trialMonths, - className = '' -}) => { - return ( - -
- {/* Icon */} -
- -
- - {/* Content */} -
-
- -

- ¡Programa Piloto Activado! -

- -
- -

- Has sido seleccionado para nuestro programa piloto exclusivo. - Disfruta de {trialMonths} meses completamente gratis como uno de nuestros primeros 20 clientes. -

- -
-
- - - Cupón: {couponCode} - -
- -
- - - {trialMonths} meses gratis - -
- -
- - - 20% descuento de por vida - -
-
- -

- El cupón se aplicará automáticamente durante el registro. Tarjeta requerida para validación, sin cargo inmediato. -

-
-
-
- ); -}; - -export default PilotBanner; diff --git a/frontend/src/components/domain/auth/RegisterForm.tsx b/frontend/src/components/domain/auth/RegisterForm.tsx index 0cbd5e88..900ed328 100644 --- a/frontend/src/components/domain/auth/RegisterForm.tsx +++ b/frontend/src/components/domain/auth/RegisterForm.tsx @@ -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 = ({ // 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('basic_info'); - const [selectedPlan, setSelectedPlan] = useState('starter'); - const [useTrial, setUseTrial] = useState(isPilot); // Auto-enable trial for pilot customers + const [selectedPlan, setSelectedPlan] = useState(preSelectedPlan || 'starter'); + const [useTrial, setUseTrial] = useState(isPilot || urlPilotParam); // Auto-enable trial for pilot customers const [bypassPayment, setBypassPayment] = useState(false); + const [selectedPlanMetadata, setSelectedPlanMetadata] = useState(null); // Helper function to determine password match status const getPasswordMatchStatus = () => { @@ -77,6 +94,58 @@ export const RegisterForm: React.FC = ({ 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 = {}; @@ -120,17 +189,25 @@ export const RegisterForm: React.FC = ({ 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 = ({ // 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 = ({ step.number )} - +
+ + {step.label} + + + + {step.time} + +
{index < steps.length - 1 && (
= ({

- {isPilot && couponCode && ( - - )} - -
@@ -544,8 +632,52 @@ export const RegisterForm: React.FC = ({

+ {/* Plan Summary Card */} + {selectedPlanMetadata && ( + +

+ + Resumen de tu Plan +

+
+
+ Plan seleccionado: + {selectedPlanMetadata.name} +
+
+ Precio mensual: + + {subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)}/mes + +
+ {useTrial && ( +
+ Período de prueba: + + {isPilot ? `${trialMonths} meses GRATIS` : '14 días gratis'} + +
+ )} +
+
+ Total hoy: + €0.00 +
+

+ {useTrial + ? `Se te cobrará ${subscriptionService.formatPrice(selectedPlanMetadata.monthly_price)} después del período de prueba` + : 'Tarjeta requerida para validación' + } +

+
+
+
+ )} + void; - showTrialOption?: boolean; - onTrialSelect?: (useTrial: boolean) => void; - trialSelected?: boolean; - className?: string; -} - -export const SubscriptionSelection: React.FC = ({ - selectedPlan, - onPlanSelect, - showTrialOption = false, - onTrialSelect, - trialSelected = false, - className = '' -}) => { - const { t } = useTranslation(); - const [availablePlans, setAvailablePlans] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
-
-
- ); - } - - if (error || !availablePlans) { - return ( -
-
-

{error || 'Error al cargar los planes'}

-
- -
- ); - } - - const handleTrialToggle = () => { - if (onTrialSelect) { - onTrialSelect(!trialSelected); - } - }; - - // Helper function to translate feature names to Spanish - const translateFeature = (feature: string): string => { - const translations: Record = { - '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 ( -
- {showTrialOption && ( - -
-
-
- -
-
-

- {t('auth:subscription.trial_title', 'Prueba gratuita')} -

-

- {t('auth:subscription.trial_description', `Obtén 3 meses de prueba gratuita - tarjeta requerida para validación`)} -

-
-
- -
-
- )} - -
- {Object.entries(availablePlans.plans).map(([planKey, plan]) => { - const isSelected = selectedPlan === planKey; - const metadata = plan as PlanMetadata; - - return ( - onPlanSelect(planKey)} - > - {/* Popular Badge */} - {metadata.popular && ( -
- - - {t('auth:subscription.popular', 'Más Popular')} - -
- )} - - {/* Card Content */} -
- {/* Header Section: Plan Info & Pricing */} -
-
-
-

{metadata.name}

- {metadata.trial_days > 0 && ( - - - 3 meses gratis - - )} -
-

{metadata.tagline}

-
- - {subscriptionService.formatPrice(metadata.monthly_price)} - - /mes -
-

{metadata.description}

- {metadata.recommended_for && ( -

- 💡 {metadata.recommended_for} -

- )} -
- - {/* Action Button - Desktop position */} -
- -
-
- - {/* Body Section: Limits & Features */} -
- {/* Plan Limits */} -
-
- -
- Límites del Plan -
-
-
-
- - - {metadata.limits.users === null ? 'Usuarios ilimitados' : `${metadata.limits.users} usuario${metadata.limits.users > 1 ? 's' : ''}`} - -
-
- - - {metadata.limits.locations === null ? 'Ubicaciones ilimitadas' : `${metadata.limits.locations} ubicación${metadata.limits.locations > 1 ? 'es' : ''}`} - -
-
- - - {metadata.limits.products === null ? 'Productos ilimitados' : `${metadata.limits.products} producto${metadata.limits.products > 1 ? 's' : ''}`} - -
- {metadata.limits.forecasts_per_day !== null && ( -
- - - {metadata.limits.forecasts_per_day} pronóstico{metadata.limits.forecasts_per_day > 1 ? 's' : ''}/día - -
- )} -
-
- - {/* Features */} -
-
- -
- {t('auth:subscription.features', 'Funcionalidades Incluidas')} -
-
- -
- {metadata.features.slice(0, 8).map((feature, index) => ( -
- - {translateFeature(feature)} -
- ))} - {metadata.features.length > 8 && ( -

- +{metadata.features.length - 8} funcionalidades más -

- )} -
- - {/* Support Level */} - {metadata.support && ( -
-

- Soporte: {metadata.support} -

-
- )} -
-
- - {/* Action Button - Mobile position */} -
- -
-
-
- ); - })} -
-
- ); -}; diff --git a/frontend/src/components/layout/PublicHeader/PublicHeader.tsx b/frontend/src/components/layout/PublicHeader/PublicHeader.tsx index a3767680..6131adab 100644 --- a/frontend/src/components/layout/PublicHeader/PublicHeader.tsx +++ b/frontend/src/components/layout/PublicHeader/PublicHeader.tsx @@ -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(({ {/* Authentication buttons - Enhanced */} {showAuthButtons && (
- + - + - + -
- - - ); - } - return (
{/* Header */} -
+

Planes que se Adaptan a tu Negocio

Sin costos ocultos, sin compromisos largos. Comienza gratis y escala según crezcas.

- - {/* Billing Cycle Toggle */} -
- - -
- {/* Plans Grid */} -
- {Object.entries(plans).map(([tier, plan]) => { - const price = getPrice(plan); - const savings = getSavings(plan); - const isPopular = plan.popular; - const tierKey = tier as SubscriptionTier; - - return ( -
- {/* Popular Badge */} - {isPopular && ( -
-
- - Más Popular -
-
- )} - - {/* Icon */} -
-
- {getPlanIcon(tierKey)} -
-
- - {/* Header */} -
-

- {plan.name} -

-

- {plan.tagline} -

-
- - {/* Pricing */} -
-
- - {subscriptionService.formatPrice(price)} - - - /{billingCycle === 'monthly' ? 'mes' : 'año'} - -
- - {/* Savings Badge */} - {savings && ( -
- Ahorra {subscriptionService.formatPrice(savings.savingsAmount)}/año -
- )} - - {/* Trial Badge */} - {!savings && ( -
- 3 meses gratis -
- )} -
- - {/* Key Limits */} -
-
-
- Usuarios: - - {plan.limits.users || 'Ilimitado'} - -
-
- Ubicaciones: - - {plan.limits.locations || 'Ilimitado'} - -
-
- Productos: - - {plan.limits.products || 'Ilimitado'} - -
-
- Pronósticos/día: - - {plan.limits.forecasts_per_day || 'Ilimitado'} - -
-
-
- - {/* Features List (first 8) */} -
- {plan.features.slice(0, 8).map((feature) => ( -
-
-
- -
-
- - {formatFeatureName(feature)} - -
- ))} - {plan.features.length > 8 && ( -

- Y {plan.features.length - 8} características más... -

- )} -
- - {/* Support */} -
- {plan.support} -
- - {/* CTA Button */} - - - - -

- 3 meses gratis • Tarjeta requerida para validación -

-
- ); - })} -
+ {/* Pricing Cards */} + {/* Feature Comparison Link */}
diff --git a/frontend/src/components/subscription/SubscriptionPricingCards.tsx b/frontend/src/components/subscription/SubscriptionPricingCards.tsx new file mode 100644 index 00000000..4e467344 --- /dev/null +++ b/frontend/src/components/subscription/SubscriptionPricingCards.tsx @@ -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 = ({ + mode = 'landing', + selectedPlan, + onPlanSelect, + showPilotBanner = false, + pilotCouponCode, + pilotTrialMonths = 3, + className = '' +}) => { + const { t } = useTranslation(); + const [plans, setPlans] = useState | null>(null); + const [billingCycle, setBillingCycle] = useState('monthly'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ; + case SUBSCRIPTION_TIERS.PROFESSIONAL: + return ; + case SUBSCRIPTION_TIERS.ENTERPRISE: + return ; + default: + return ; + } + }; + + const formatFeatureName = (feature: string): string => { + const featureNames: Record = { + '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 ( +
+ + Cargando planes... +
+ ); + } + + if (error || !plans) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ {/* Pilot Program Banner */} + {showPilotBanner && pilotCouponCode && mode === 'selection' && ( + +
+
+
+ +
+
+
+

+ Programa Piloto Activo +

+

+ Como participante del programa piloto, obtienes {pilotTrialMonths} meses completamente gratis en el plan que elijas, + más un 20% de descuento de por vida si decides continuar. +

+
+
+
+ )} + + {/* Billing Cycle Toggle */} +
+
+ + +
+
+ + {/* Plans Grid */} +
+ {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 ( + + {/* Popular Badge */} + {isPopular && ( +
+
+ + Más Popular +
+
+ )} + + {/* Icon */} +
+
+ {getPlanIcon(tierKey)} +
+
+ + {/* Header */} +
+

+ {plan.name} +

+

+ {plan.tagline} +

+
+ + {/* Pricing */} +
+
+ + {subscriptionService.formatPrice(price)} + + + /{billingCycle === 'monthly' ? 'mes' : 'año'} + +
+ + {/* Savings Badge */} + {savings && ( +
+ Ahorra {subscriptionService.formatPrice(savings.savingsAmount)}/año +
+ )} + + {/* Trial Badge */} + {!savings && ( +
+ 3 meses gratis +
+ )} +
+ + {/* Key Limits */} +
+
+
+ Usuarios: + + {plan.limits.users || 'Ilimitado'} + +
+
+ Ubicaciones: + + {plan.limits.locations || 'Ilimitado'} + +
+
+ Productos: + + {plan.limits.products || 'Ilimitado'} + +
+
+ Pronósticos/día: + + {plan.limits.forecasts_per_day || 'Ilimitado'} + +
+
+
+ + {/* Features List */} +
+ {plan.features.slice(0, 8).map((feature) => ( +
+
+
+ +
+
+ + {formatFeatureName(feature)} + +
+ ))} + {plan.features.length > 8 && ( +

+ Y {plan.features.length - 8} características más... +

+ )} +
+ + {/* Support */} +
+ {plan.support} +
+ + {/* CTA Button */} + {mode === 'landing' ? ( + + ) : ( + + )} + +

+ 3 meses gratis • Tarjeta requerida para validación +

+
+ ); + })} +
+
+ ); +}; diff --git a/frontend/src/components/subscription/index.ts b/frontend/src/components/subscription/index.ts index e724f239..8dad1af0 100644 --- a/frontend/src/components/subscription/index.ts +++ b/frontend/src/components/subscription/index.ts @@ -1 +1,2 @@ export { PricingSection } from './PricingSection'; +export { SubscriptionPricingCards } from './SubscriptionPricingCards'; diff --git a/frontend/src/config/pilot.ts b/frontend/src/config/pilot.ts new file mode 100644 index 00000000..1f848678 --- /dev/null +++ b/frontend/src/config/pilot.ts @@ -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; diff --git a/frontend/src/hooks/usePilotDetection.ts b/frontend/src/hooks/usePilotDetection.ts index 9b9ac4d0..bd733f1e 100644 --- a/frontend/src/hooks/usePilotDetection.ts +++ b/frontend/src/hooks/usePilotDetection.ts @@ -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]); diff --git a/frontend/src/pages/public/LandingPage.tsx b/frontend/src/pages/public/LandingPage.tsx index 0d562bdf..675cc318 100644 --- a/frontend/src/pages/public/LandingPage.tsx +++ b/frontend/src/pages/public/LandingPage.tsx @@ -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 = () => {
- + - + - +