Update landing page
This commit is contained in:
5
Tiltfile
5
Tiltfile
@@ -225,6 +225,11 @@ k8s_resource('demo-seed-subscriptions',
|
|||||||
resource_deps=['tenant-migration', 'demo-seed-tenants'],
|
resource_deps=['tenant-migration', 'demo-seed-tenants'],
|
||||||
labels=['demo-init'])
|
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
|
# Weight 15: Seed inventory - CRITICAL: All other seeds depend on this
|
||||||
k8s_resource('demo-seed-inventory',
|
k8s_resource('demo-seed-inventory',
|
||||||
resource_deps=['inventory-migration', 'demo-seed-tenants'],
|
resource_deps=['inventory-migration', 'demo-seed-tenants'],
|
||||||
|
|||||||
@@ -18,12 +18,13 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<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>
|
<title>Bakery AI - Gestión Inteligente para Panaderías</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!-- Runtime configuration - loaded by Kubernetes deployment -->
|
|
||||||
<script src="/runtime-config.js"></script>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -10,6 +10,8 @@ interface PaymentFormProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
bypassPayment?: boolean;
|
bypassPayment?: boolean;
|
||||||
onBypassToggle?: () => void;
|
onBypassToggle?: () => void;
|
||||||
|
userName?: string;
|
||||||
|
userEmail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PaymentForm: React.FC<PaymentFormProps> = ({
|
const PaymentForm: React.FC<PaymentFormProps> = ({
|
||||||
@@ -17,7 +19,9 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
|||||||
onPaymentError,
|
onPaymentError,
|
||||||
className = '',
|
className = '',
|
||||||
bypassPayment = false,
|
bypassPayment = false,
|
||||||
onBypassToggle
|
onBypassToggle,
|
||||||
|
userName = '',
|
||||||
|
userEmail = ''
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const stripe = useStripe();
|
const stripe = useStripe();
|
||||||
@@ -26,8 +30,8 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [cardComplete, setCardComplete] = useState(false);
|
const [cardComplete, setCardComplete] = useState(false);
|
||||||
const [billingDetails, setBillingDetails] = useState({
|
const [billingDetails, setBillingDetails] = useState({
|
||||||
name: '',
|
name: userName,
|
||||||
email: '',
|
email: userEmail,
|
||||||
address: {
|
address: {
|
||||||
line1: '',
|
line1: '',
|
||||||
city: '',
|
city: '',
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Input, Card } from '../../ui';
|
import { Button, Input, Card } from '../../ui';
|
||||||
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||||
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
||||||
import { useToast } from '../../../hooks/ui/useToast';
|
import { useToast } from '../../../hooks/ui/useToast';
|
||||||
import { SubscriptionSelection } from './SubscriptionSelection';
|
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
|
||||||
import PaymentForm from './PaymentForm';
|
import PaymentForm from './PaymentForm';
|
||||||
import PilotBanner from './PilotBanner';
|
|
||||||
import { loadStripe } from '@stripe/stripe-js';
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
import { Elements } from '@stripe/react-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 { usePilotDetection } from '../../../hooks/usePilotDetection';
|
||||||
|
import { subscriptionService } from '../../../api';
|
||||||
|
|
||||||
// Initialize Stripe - In production, use environment variable
|
// Helper to get Stripe key from runtime config or build-time env
|
||||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_test_51234567890123456789012345678901234567890123456789012345678901234567890123456789012345');
|
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 {
|
interface RegisterFormProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
@@ -62,11 +73,17 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
// Detect pilot program participation
|
// Detect pilot program participation
|
||||||
const { isPilot, couponCode, trialMonths } = usePilotDetection();
|
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
|
// Multi-step form state
|
||||||
const [currentStep, setCurrentStep] = useState<RegistrationStep>('basic_info');
|
const [currentStep, setCurrentStep] = useState<RegistrationStep>('basic_info');
|
||||||
const [selectedPlan, setSelectedPlan] = useState<string>('starter');
|
const [selectedPlan, setSelectedPlan] = useState<string>(preSelectedPlan || 'starter');
|
||||||
const [useTrial, setUseTrial] = useState<boolean>(isPilot); // Auto-enable trial for pilot customers
|
const [useTrial, setUseTrial] = useState<boolean>(isPilot || urlPilotParam); // Auto-enable trial for pilot customers
|
||||||
const [bypassPayment, setBypassPayment] = useState<boolean>(false);
|
const [bypassPayment, setBypassPayment] = useState<boolean>(false);
|
||||||
|
const [selectedPlanMetadata, setSelectedPlanMetadata] = useState<any>(null);
|
||||||
|
|
||||||
// Helper function to determine password match status
|
// Helper function to determine password match status
|
||||||
const getPasswordMatchStatus = () => {
|
const getPasswordMatchStatus = () => {
|
||||||
@@ -77,6 +94,58 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
|
|
||||||
const passwordMatchStatus = getPasswordMatchStatus();
|
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 validateForm = (): boolean => {
|
||||||
const newErrors: Partial<SimpleUserRegistration> = {};
|
const newErrors: Partial<SimpleUserRegistration> = {};
|
||||||
|
|
||||||
@@ -120,17 +189,25 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// OPTION A: Skip subscription step if plan was pre-selected from pricing page
|
||||||
|
if (preSelectedPlan) {
|
||||||
|
setCurrentStep('payment');
|
||||||
|
} else {
|
||||||
setCurrentStep('subscription');
|
setCurrentStep('subscription');
|
||||||
|
}
|
||||||
} else if (currentStep === 'subscription') {
|
} else if (currentStep === 'subscription') {
|
||||||
setCurrentStep('payment');
|
setCurrentStep('payment');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviousStep = () => {
|
const handlePreviousStep = () => {
|
||||||
if (currentStep === 'subscription') {
|
if (currentStep === 'payment' && preSelectedPlan) {
|
||||||
|
// Go back to basic_info (skip subscription step)
|
||||||
setCurrentStep('basic_info');
|
setCurrentStep('basic_info');
|
||||||
} else if (currentStep === 'payment') {
|
} else if (currentStep === 'payment') {
|
||||||
setCurrentStep('subscription');
|
setCurrentStep('subscription');
|
||||||
|
} else if (currentStep === 'subscription') {
|
||||||
|
setCurrentStep('basic_info');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,10 +267,16 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
|
|
||||||
// Render step indicator
|
// Render step indicator
|
||||||
const renderStepIndicator = () => {
|
const renderStepIndicator = () => {
|
||||||
const steps = [
|
// Show 2 steps if plan is pre-selected, 3 steps otherwise
|
||||||
{ key: 'basic_info', label: 'Información', number: 1 },
|
const steps = preSelectedPlan
|
||||||
{ key: 'subscription', label: 'Plan', number: 2 },
|
? [
|
||||||
{ key: 'payment', label: 'Pago', number: 3 }
|
{ 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) => {
|
const getStepIndex = (step: RegistrationStep) => {
|
||||||
@@ -221,11 +304,19 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
|||||||
step.number
|
step.number
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`mt-1 sm:mt-2 text-[10px] sm:text-xs font-medium hidden sm:block ${
|
<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'
|
index <= currentIndex ? 'text-text-primary' : 'text-text-secondary'
|
||||||
}`}>
|
}`}>
|
||||||
{step.label}
|
{step.label}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
<div className={`h-0.5 w-8 sm:w-16 mx-2 sm:mx-4 transition-all duration-200 ${
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPilot && couponCode && (
|
<SubscriptionPricingCards
|
||||||
<PilotBanner couponCode={couponCode} trialMonths={trialMonths} />
|
mode="selection"
|
||||||
)}
|
|
||||||
|
|
||||||
<SubscriptionSelection
|
|
||||||
selectedPlan={selectedPlan}
|
selectedPlan={selectedPlan}
|
||||||
onPlanSelect={setSelectedPlan}
|
onPlanSelect={setSelectedPlan}
|
||||||
showTrialOption={!isPilot}
|
showPilotBanner={isPilot}
|
||||||
onTrialSelect={setUseTrial}
|
pilotCouponCode={couponCode}
|
||||||
trialSelected={useTrial}
|
pilotTrialMonths={trialMonths}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-4 pt-2 border-t border-border-primary">
|
<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>
|
</p>
|
||||||
</div>
|
</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}>
|
<Elements stripe={stripePromise}>
|
||||||
<PaymentForm
|
<PaymentForm
|
||||||
|
userName={formData.full_name}
|
||||||
|
userEmail={formData.email}
|
||||||
onPaymentSuccess={handlePaymentSuccess}
|
onPaymentSuccess={handlePaymentSuccess}
|
||||||
onPaymentError={handlePaymentError}
|
onPaymentError={handlePaymentError}
|
||||||
bypassPayment={bypassPayment}
|
bypassPayment={bypassPayment}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, ThemeToggle } from '../../ui';
|
import { Button, ThemeToggle } from '../../ui';
|
||||||
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
import { CompactLanguageSelector } from '../../ui/LanguageSelector';
|
||||||
|
import { getRegisterUrl, getLoginUrl } from '../../../utils/navigation';
|
||||||
|
|
||||||
export interface PublicHeaderProps {
|
export interface PublicHeaderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -176,7 +177,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
|||||||
{/* Authentication buttons - Enhanced */}
|
{/* Authentication buttons - Enhanced */}
|
||||||
{showAuthButtons && (
|
{showAuthButtons && (
|
||||||
<div className="flex items-center gap-2 lg:gap-3">
|
<div className="flex items-center gap-2 lg:gap-3">
|
||||||
<Link to="/login">
|
<Link to={getLoginUrl()}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -185,7 +186,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
|||||||
{t('common:header.login')}
|
{t('common:header.login')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/register">
|
<Link to={getRegisterUrl()}>
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
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"
|
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 */}
|
{/* Mobile auth buttons */}
|
||||||
{showAuthButtons && (
|
{showAuthButtons && (
|
||||||
<div className="flex flex-col gap-3 pt-4 sm:hidden">
|
<div className="flex flex-col gap-3 pt-4 sm:hidden">
|
||||||
<Link to="/login">
|
<Link to={getLoginUrl()}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="md"
|
size="md"
|
||||||
@@ -257,7 +258,7 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
|||||||
{t('common:header.login')}
|
{t('common:header.login')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/register">
|
<Link to={getRegisterUrl()}>
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
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"
|
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"
|
||||||
|
|||||||
@@ -1,357 +1,24 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Check, Star, ArrowRight, Package, TrendingUp, Settings, Loader } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { Button } from '../ui';
|
import { SubscriptionPricingCards } from './SubscriptionPricingCards';
|
||||||
import {
|
|
||||||
subscriptionService,
|
|
||||||
type PlanMetadata,
|
|
||||||
type SubscriptionTier,
|
|
||||||
SUBSCRIPTION_TIERS
|
|
||||||
} from '../../api';
|
|
||||||
|
|
||||||
type BillingCycle = 'monthly' | 'yearly';
|
|
||||||
|
|
||||||
export const PricingSection: React.FC = () => {
|
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 (
|
return (
|
||||||
<section id="pricing" className="py-24 bg-[var(--bg-primary)]">
|
<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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
<h2 className="text-3xl lg:text-4xl font-extrabold text-[var(--text-primary)]">
|
||||||
Planes que se Adaptan a tu Negocio
|
Planes que se Adaptan a tu Negocio
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-2xl mx-auto text-lg text-[var(--text-secondary)]">
|
<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.
|
Sin costos ocultos, sin compromisos largos. Comienza gratis y escala según crezcas.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Plans Grid */}
|
{/* Pricing Cards */}
|
||||||
<div className="mt-16 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<SubscriptionPricingCards mode="landing" />
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Feature Comparison Link */}
|
{/* Feature Comparison Link */}
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { PricingSection } from './PricingSection';
|
export { PricingSection } from './PricingSection';
|
||||||
|
export { SubscriptionPricingCards } from './SubscriptionPricingCards';
|
||||||
|
|||||||
92
frontend/src/config/pilot.ts
Normal file
92
frontend/src/config/pilot.ts
Normal 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;
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Custom hook to detect pilot program participation via URL parameter
|
* Custom hook to detect pilot program participation
|
||||||
* Checks for ?pilot=true in URL and provides pilot status and coupon code
|
*
|
||||||
|
* 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 { useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import PILOT_CONFIG from '../config/pilot';
|
||||||
|
|
||||||
interface PilotDetectionResult {
|
interface PilotDetectionResult {
|
||||||
isPilot: boolean;
|
isPilot: boolean;
|
||||||
@@ -16,15 +21,18 @@ export const usePilotDetection = (): PilotDetectionResult => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const pilotInfo = useMemo(() => {
|
const pilotInfo = useMemo(() => {
|
||||||
|
// Check URL parameter
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
const pilotParam = searchParams.get('pilot');
|
const urlPilotParam = searchParams.get('pilot') === 'true';
|
||||||
const isPilot = pilotParam === 'true';
|
|
||||||
|
// Pilot mode is active if EITHER env var is true OR URL param is true
|
||||||
|
const isPilot = PILOT_CONFIG.enabled || urlPilotParam;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPilot,
|
isPilot,
|
||||||
couponCode: isPilot ? 'PILOT2025' : null,
|
couponCode: isPilot ? PILOT_CONFIG.couponCode : null,
|
||||||
trialMonths: isPilot ? 3 : 0,
|
trialMonths: isPilot ? PILOT_CONFIG.trialMonths : 0,
|
||||||
trialDays: isPilot ? 90 : 14,
|
trialDays: isPilot ? PILOT_CONFIG.trialDays : 14,
|
||||||
};
|
};
|
||||||
}, [location.search]);
|
}, [location.search]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Button } from '../../components/ui';
|
import { Button } from '../../components/ui';
|
||||||
import { PublicLayout } from '../../components/layout';
|
import { PublicLayout } from '../../components/layout';
|
||||||
import { PricingSection } from '../../components/subscription';
|
import { PricingSection } from '../../components/subscription';
|
||||||
|
import { getRegisterUrl, getDemoUrl } from '../../utils/navigation';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -116,7 +117,7 @@ const LandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-col sm:flex-row gap-5 justify-center items-center">
|
<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
|
<Button
|
||||||
size="lg"
|
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"
|
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>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/demo" className="w-full sm:w-auto">
|
<Link to={getDemoUrl()} className="w-full sm:w-auto">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -901,7 +902,7 @@ const LandingPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-col sm:flex-row gap-6 justify-center">
|
<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
|
<Button
|
||||||
size="lg"
|
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"
|
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>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/demo" className="w-full sm:w-auto">
|
<Link to={getDemoUrl()} className="w-full sm:w-auto">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const RegisterPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<PublicLayout
|
<PublicLayout
|
||||||
variant="centered"
|
variant="centered"
|
||||||
maxWidth="xl"
|
maxWidth="7xl"
|
||||||
headerProps={{
|
headerProps={{
|
||||||
showThemeToggle: true,
|
showThemeToggle: true,
|
||||||
showAuthButtons: false,
|
showAuthButtons: false,
|
||||||
|
|||||||
71
frontend/src/utils/navigation.ts
Normal file
71
frontend/src/utils/navigation.ts
Normal 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');
|
||||||
|
};
|
||||||
17
frontend/src/vite-env.d.ts
vendored
17
frontend/src/vite-env.d.ts
vendored
@@ -6,9 +6,26 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_APP_TITLE: string
|
readonly VITE_APP_TITLE: string
|
||||||
readonly VITE_APP_VERSION: string
|
readonly VITE_APP_VERSION: string
|
||||||
readonly VITE_ENVIRONMENT: 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...
|
// more env variables...
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,17 +10,31 @@ elif [ -z "$VITE_API_URL" ]; then
|
|||||||
export VITE_API_URL="/api"
|
export VITE_API_URL="/api"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Default values for other environment variables
|
# Default values for environment variables
|
||||||
export VITE_APP_TITLE=${VITE_APP_TITLE:-"PanIA Dashboard"}
|
export VITE_APP_TITLE=${VITE_APP_TITLE:-"PanIA Dashboard"}
|
||||||
export VITE_APP_VERSION=${VITE_APP_VERSION:-"1.0.0"}
|
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
|
# Create a runtime configuration file that can be loaded by the frontend
|
||||||
cat > /usr/share/nginx/html/runtime-config.js << EOL
|
cat > /usr/share/nginx/html/runtime-config.js << EOL
|
||||||
window.__RUNTIME_CONFIG__ = {
|
window.__RUNTIME_CONFIG__ = {
|
||||||
VITE_API_URL: '${VITE_API_URL}',
|
VITE_API_URL: '${VITE_API_URL}',
|
||||||
VITE_APP_TITLE: '${VITE_APP_TITLE}',
|
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
|
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}"
|
||||||
|
|||||||
@@ -323,6 +323,12 @@ data:
|
|||||||
VITE_API_URL: "/api"
|
VITE_API_URL: "/api"
|
||||||
VITE_ENVIRONMENT: "production"
|
VITE_ENVIRONMENT: "production"
|
||||||
|
|
||||||
|
# Pilot Program Configuration
|
||||||
|
VITE_PILOT_MODE_ENABLED: "true"
|
||||||
|
VITE_PILOT_COUPON_CODE: "PILOT2025"
|
||||||
|
VITE_PILOT_TRIAL_MONTHS: "3"
|
||||||
|
VITE_STRIPE_PUBLISHABLE_KEY: "pk_test_your_stripe_publishable_key_here"
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# LOCATION SETTINGS (Nominatim Geocoding)
|
# LOCATION SETTINGS (Nominatim Geocoding)
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ spec:
|
|||||||
app.kubernetes.io/name: tenant-seed-pilot-coupon
|
app.kubernetes.io/name: tenant-seed-pilot-coupon
|
||||||
app.kubernetes.io/component: seed
|
app.kubernetes.io/component: seed
|
||||||
spec:
|
spec:
|
||||||
|
serviceAccountName: demo-seed-sa
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: wait-for-db
|
- name: wait-for-db
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -40,7 +41,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: seed-coupon
|
- name: seed-coupon
|
||||||
image: bakery/tenant-service:dev
|
image: bakery/tenant-service:dev
|
||||||
command: ["python", "/app/services/tenant/scripts/seed_pilot_coupon.py"]
|
command: ["python", "/app/scripts/seed_pilot_coupon.py"]
|
||||||
env:
|
env:
|
||||||
- name: TENANT_DATABASE_URL
|
- name: TENANT_DATABASE_URL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
|||||||
796
regenerate_migrations_k8s.sh
Executable file
796
regenerate_migrations_k8s.sh
Executable file
@@ -0,0 +1,796 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to regenerate Alembic migrations using Kubernetes local dev environment
|
||||||
|
# This script backs up existing migrations and generates new ones based on current models
|
||||||
|
|
||||||
|
set -euo pipefail # Exit on error, undefined variables, and pipe failures
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
NAMESPACE="${KUBE_NAMESPACE:-bakery-ia}"
|
||||||
|
LOG_FILE="migration_script_$(date +%Y%m%d_%H%M%S).log"
|
||||||
|
BACKUP_RETENTION_DAYS=7
|
||||||
|
CONTAINER_SUFFIX="service" # Default container name suffix (e.g., pos-service)
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
DRY_RUN=false
|
||||||
|
SKIP_BACKUP=false
|
||||||
|
APPLY_MIGRATIONS=false
|
||||||
|
CHECK_EXISTING=false
|
||||||
|
VERBOSE=false
|
||||||
|
SKIP_DB_CHECK=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
|
--skip-backup) SKIP_BACKUP=true; shift ;;
|
||||||
|
--apply) APPLY_MIGRATIONS=true; shift ;;
|
||||||
|
--check-existing) CHECK_EXISTING=true; shift ;;
|
||||||
|
--verbose) VERBOSE=true; shift ;;
|
||||||
|
--skip-db-check) SKIP_DB_CHECK=true; shift ;;
|
||||||
|
--namespace) NAMESPACE="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
echo "Usage: $0 [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --dry-run Show what would be done without making changes"
|
||||||
|
echo " --skip-backup Skip backing up existing migrations"
|
||||||
|
echo " --apply Automatically apply migrations after generation"
|
||||||
|
echo " --check-existing Check for and copy existing migrations from pods first"
|
||||||
|
echo " --verbose Enable detailed logging"
|
||||||
|
echo " --skip-db-check Skip database connectivity check"
|
||||||
|
echo " --namespace NAME Use specific Kubernetes namespace (default: bakery-ia)"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 --namespace dev --dry-run # Simulate migration regeneration"
|
||||||
|
echo " $0 --apply --verbose # Generate and apply migrations with detailed logs"
|
||||||
|
echo " $0 --skip-db-check # Skip database connectivity check"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Unknown option: $1"; echo "Use --help for usage information"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# List of all services
|
||||||
|
SERVICES=(
|
||||||
|
"pos" "sales" "recipes" "training" "auth" "orders" "inventory"
|
||||||
|
"suppliers" "tenant" "notification" "alert-processor" "forecasting"
|
||||||
|
"external" "production" "demo-session"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backup directory
|
||||||
|
BACKUP_DIR="migrations_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Initialize log file
|
||||||
|
touch "$LOG_FILE"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting migration regeneration" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Function to perform pre-flight checks
|
||||||
|
preflight_checks() {
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}Pre-flight Checks${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local checks_passed=true
|
||||||
|
|
||||||
|
# Check kubectl
|
||||||
|
echo -e "${YELLOW}Checking kubectl...${NC}"
|
||||||
|
if ! command -v kubectl &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ kubectl not found${NC}"
|
||||||
|
log_message "ERROR" "kubectl not found"
|
||||||
|
checks_passed=false
|
||||||
|
else
|
||||||
|
KUBECTL_VERSION=$(kubectl version --client --short 2>/dev/null | grep -oP 'v\d+\.\d+\.\d+' || echo "unknown")
|
||||||
|
echo -e "${GREEN}✓ kubectl found (version: $KUBECTL_VERSION)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check cluster connectivity
|
||||||
|
echo -e "${YELLOW}Checking Kubernetes cluster connectivity...${NC}"
|
||||||
|
if ! kubectl cluster-info &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ Cannot connect to Kubernetes cluster${NC}"
|
||||||
|
log_message "ERROR" "Cannot connect to Kubernetes cluster"
|
||||||
|
checks_passed=false
|
||||||
|
else
|
||||||
|
CLUSTER_NAME=$(kubectl config current-context 2>/dev/null || echo "unknown")
|
||||||
|
echo -e "${GREEN}✓ Connected to cluster: $CLUSTER_NAME${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check namespace exists
|
||||||
|
echo -e "${YELLOW}Checking namespace '$NAMESPACE'...${NC}"
|
||||||
|
if ! kubectl get namespace "$NAMESPACE" &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ Namespace '$NAMESPACE' not found${NC}"
|
||||||
|
log_message "ERROR" "Namespace '$NAMESPACE' not found"
|
||||||
|
checks_passed=false
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Namespace exists${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if all service pods are running
|
||||||
|
echo -e "${YELLOW}Checking service pods...${NC}"
|
||||||
|
local pods_found=0
|
||||||
|
local pods_running=0
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
local pod_name=$(kubectl get pods -n "$NAMESPACE" -l "app.kubernetes.io/name=${service}-service" --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||||
|
if [ -n "$pod_name" ]; then
|
||||||
|
pods_found=$((pods_found + 1))
|
||||||
|
pods_running=$((pods_running + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo -e "${GREEN}✓ Found $pods_running/${#SERVICES[@]} service pods running${NC}"
|
||||||
|
|
||||||
|
if [ $pods_running -lt ${#SERVICES[@]} ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Not all service pods are running${NC}"
|
||||||
|
echo -e "${YELLOW} Missing services will be skipped${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check database connectivity for running services
|
||||||
|
echo -e "${YELLOW}Checking database connectivity (sample)...${NC}"
|
||||||
|
local sample_service="auth"
|
||||||
|
local sample_pod=$(kubectl get pods -n "$NAMESPACE" -l "app.kubernetes.io/name=${sample_service}-service" --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$sample_pod" ]; then
|
||||||
|
local db_check=$(kubectl exec -n "$NAMESPACE" "$sample_pod" -c "${sample_service}-service" -- sh -c "python3 -c 'import asyncpg; print(\"OK\")' 2>/dev/null" || echo "FAIL")
|
||||||
|
if [ "$db_check" = "OK" ]; then
|
||||||
|
echo -e "${GREEN}✓ Database drivers available (asyncpg)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Database driver check failed${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Cannot check database connectivity (no sample pod running)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check local directory structure
|
||||||
|
echo -e "${YELLOW}Checking local directory structure...${NC}"
|
||||||
|
local dirs_found=0
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
local service_dir=$(echo "$service" | tr '-' '_')
|
||||||
|
if [ -d "services/$service_dir/migrations" ]; then
|
||||||
|
dirs_found=$((dirs_found + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo -e "${GREEN}✓ Found $dirs_found/${#SERVICES[@]} service migration directories${NC}"
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
echo -e "${YELLOW}Checking disk space...${NC}"
|
||||||
|
local available_space=$(df -h . | tail -1 | awk '{print $4}')
|
||||||
|
echo -e "${GREEN}✓ Available disk space: $available_space${NC}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$checks_passed" = false ]; then
|
||||||
|
echo -e "${RED}========================================${NC}"
|
||||||
|
echo -e "${RED}Pre-flight checks failed!${NC}"
|
||||||
|
echo -e "${RED}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${RED}Aborted.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}All pre-flight checks passed!${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run pre-flight checks
|
||||||
|
preflight_checks
|
||||||
|
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}Migration Regeneration Script (K8s)${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo -e "${YELLOW}🔍 DRY RUN MODE - No changes will be made${NC}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}This script will:${NC}"
|
||||||
|
if [ "$CHECK_EXISTING" = true ]; then
|
||||||
|
echo -e "${YELLOW}1. Check for existing migrations in pods and copy them${NC}"
|
||||||
|
fi
|
||||||
|
if [ "$SKIP_BACKUP" = false ]; then
|
||||||
|
echo -e "${YELLOW}2. Backup existing migration files${NC}"
|
||||||
|
fi
|
||||||
|
echo -e "${YELLOW}3. Generate new migrations in Kubernetes pods${NC}"
|
||||||
|
echo -e "${YELLOW}4. Copy generated files back to local machine${NC}"
|
||||||
|
if [ "$APPLY_MIGRATIONS" = true ]; then
|
||||||
|
echo -e "${YELLOW}5. Apply migrations to databases${NC}"
|
||||||
|
fi
|
||||||
|
if [ "$SKIP_BACKUP" = false ]; then
|
||||||
|
echo -e "${YELLOW}6. Keep the backup in: $BACKUP_DIR${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Using Kubernetes namespace: $NAMESPACE${NC}"
|
||||||
|
echo -e "${YELLOW}Logs will be saved to: $LOG_FILE${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = false ]; then
|
||||||
|
read -p "Continue? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${RED}Aborted.${NC}"
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Aborted by user" >> "$LOG_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kubernetes setup already verified in pre-flight checks
|
||||||
|
|
||||||
|
# Function to get a running pod for a service
|
||||||
|
get_running_pod() {
|
||||||
|
local service=$1
|
||||||
|
local pod_name=""
|
||||||
|
local selectors=(
|
||||||
|
"app.kubernetes.io/name=${service}-service,app.kubernetes.io/component=microservice"
|
||||||
|
"app.kubernetes.io/name=${service}-service,app.kubernetes.io/component=worker"
|
||||||
|
"app.kubernetes.io/name=${service}-service"
|
||||||
|
"app=${service}-service,component=${service}" # Fallback for demo-session
|
||||||
|
"app=${service}-service" # Additional fallback
|
||||||
|
)
|
||||||
|
|
||||||
|
for selector in "${selectors[@]}"; do
|
||||||
|
pod_name=$(kubectl get pods -n "$NAMESPACE" -l "$selector" --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
|
||||||
|
if [ -n "$pod_name" ]; then
|
||||||
|
echo "$pod_name"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to log messages
|
||||||
|
log_message() {
|
||||||
|
local level=$1
|
||||||
|
local message=$2
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $level: $message" >> "$LOG_FILE"
|
||||||
|
if [ "$VERBOSE" = true ] || [ "$level" = "ERROR" ]; then
|
||||||
|
echo -e "${YELLOW}$message${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for existing migrations in pods if requested
|
||||||
|
if [ "$CHECK_EXISTING" = true ]; then
|
||||||
|
echo -e "${BLUE}Step 1.5: Checking for existing migrations in pods...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
FOUND_COUNT=0
|
||||||
|
COPIED_COUNT=0
|
||||||
|
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
service_dir=$(echo "$service" | tr '-' '_')
|
||||||
|
echo -e "${YELLOW}Checking $service...${NC}"
|
||||||
|
|
||||||
|
# Find a running pod
|
||||||
|
POD_NAME=$(get_running_pod "$service")
|
||||||
|
if [ -z "$POD_NAME" ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Pod not found, skipping${NC}"
|
||||||
|
log_message "WARNING" "No running pod found for $service"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check container availability
|
||||||
|
CONTAINER="${service}-${CONTAINER_SUFFIX}"
|
||||||
|
if ! kubectl get pod -n "$NAMESPACE" "$POD_NAME" -o jsonpath='{.spec.containers[*].name}' | grep -qw "$CONTAINER"; then
|
||||||
|
echo -e "${RED}✗ Container $CONTAINER not found in pod $POD_NAME, skipping${NC}"
|
||||||
|
log_message "ERROR" "Container $CONTAINER not found in pod $POD_NAME for $service"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if migration files exist in the pod
|
||||||
|
EXISTING_FILES=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "ls /app/migrations/versions/*.py 2>/dev/null | grep -v __pycache__ | grep -v __init__.py" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_FILES" ]; then
|
||||||
|
FILE_COUNT=$(echo "$EXISTING_FILES" | wc -l | tr -d ' ')
|
||||||
|
echo -e "${GREEN}✓ Found $FILE_COUNT migration file(s) in pod${NC}"
|
||||||
|
FOUND_COUNT=$((FOUND_COUNT + 1))
|
||||||
|
|
||||||
|
# Create local versions directory
|
||||||
|
mkdir -p "services/$service_dir/migrations/versions"
|
||||||
|
|
||||||
|
# Copy each file
|
||||||
|
for pod_file in $EXISTING_FILES; do
|
||||||
|
filename=$(basename "$pod_file")
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo -e "${BLUE}[DRY RUN] Would copy: $filename${NC}"
|
||||||
|
log_message "INFO" "[DRY RUN] Would copy $filename for $service"
|
||||||
|
else
|
||||||
|
if kubectl cp -n "$NAMESPACE" "$POD_NAME:$pod_file" "services/$service_dir/migrations/versions/$filename" -c "$CONTAINER" 2>>"$LOG_FILE"; then
|
||||||
|
echo -e "${GREEN}✓ Copied: $filename${NC}"
|
||||||
|
COPIED_COUNT=$((COPIED_COUNT + 1))
|
||||||
|
log_message "INFO" "Copied $filename for $service"
|
||||||
|
# Display brief summary
|
||||||
|
echo -e "${BLUE}Preview:${NC}"
|
||||||
|
grep "def upgrade" "services/$service_dir/migrations/versions/$filename" | head -1
|
||||||
|
grep "op\." "services/$service_dir/migrations/versions/$filename" | head -3 | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Failed to copy: $filename${NC}"
|
||||||
|
log_message "ERROR" "Failed to copy $filename for $service"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ No migration files found in pod${NC}"
|
||||||
|
log_message "WARNING" "No migration files found in pod for $service"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}Existing Migrations Check Summary${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${GREEN}Services with migrations: $FOUND_COUNT${NC}"
|
||||||
|
if [ "$DRY_RUN" = false ]; then
|
||||||
|
echo -e "${GREEN}Files copied: $COPIED_COUNT${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$FOUND_COUNT" = 0 ] && [ "$DRY_RUN" = false ]; then
|
||||||
|
read -p "Do you want to continue with regeneration? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo -e "${YELLOW}Stopping. Existing migrations have been copied.${NC}"
|
||||||
|
log_message "INFO" "Stopped after copying existing migrations"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup existing migrations
|
||||||
|
if [ "$SKIP_BACKUP" = false ] && [ "$DRY_RUN" = false ]; then
|
||||||
|
echo -e "${BLUE}Step 2: Backing up existing migrations...${NC}"
|
||||||
|
BACKUP_COUNT=0
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
service_dir=$(echo "$service" | tr '-' '_')
|
||||||
|
if [ -d "services/$service_dir/migrations/versions" ] && [ -n "$(ls services/$service_dir/migrations/versions/*.py 2>/dev/null)" ]; then
|
||||||
|
echo -e "${YELLOW}Backing up $service migrations...${NC}"
|
||||||
|
mkdir -p "$BACKUP_DIR/$service_dir/versions"
|
||||||
|
cp -r "services/$service_dir/migrations/versions/"*.py "$BACKUP_DIR/$service_dir/versions/" 2>>"$LOG_FILE"
|
||||||
|
BACKUP_COUNT=$((BACKUP_COUNT + 1))
|
||||||
|
log_message "INFO" "Backed up migrations for $service to $BACKUP_DIR/$service_dir/versions"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}No migration files to backup for $service${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$BACKUP_COUNT" -gt 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Backup complete: $BACKUP_DIR ($BACKUP_COUNT services)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}No migrations backed up (no migration files found)${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
elif [ "$SKIP_BACKUP" = true ]; then
|
||||||
|
echo -e "${YELLOW}Skipping backup step (--skip-backup flag)${NC}"
|
||||||
|
log_message "INFO" "Backup skipped due to --skip-backup flag"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up old backups
|
||||||
|
find . -maxdepth 1 -type d -name 'migrations_backup_*' -mtime +"$BACKUP_RETENTION_DAYS" -exec rm -rf {} \; 2>/dev/null || true
|
||||||
|
log_message "INFO" "Cleaned up backups older than $BACKUP_RETENTION_DAYS days"
|
||||||
|
|
||||||
|
echo -e "${BLUE}Step 3: Generating new migrations in Kubernetes...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SUCCESS_COUNT=0
|
||||||
|
FAILED_COUNT=0
|
||||||
|
FAILED_SERVICES=()
|
||||||
|
|
||||||
|
# Function to process a single service
|
||||||
|
process_service() {
|
||||||
|
local service=$1
|
||||||
|
local service_dir=$(echo "$service" | tr '-' '_')
|
||||||
|
local db_env_var=$(echo "$service" | tr '[:lower:]-' '[:upper:]_')_DATABASE_URL # e.g., pos -> POS_DATABASE_URL, alert-processor -> ALERT_PROCESSOR_DATABASE_URL
|
||||||
|
|
||||||
|
echo -e "${BLUE}----------------------------------------${NC}"
|
||||||
|
echo -e "${BLUE}Processing: $service${NC}"
|
||||||
|
echo -e "${BLUE}----------------------------------------${NC}"
|
||||||
|
log_message "INFO" "Starting migration generation for $service"
|
||||||
|
|
||||||
|
# Skip if no local migrations directory and --check-existing is not set
|
||||||
|
if [ ! -d "services/$service_dir/migrations/versions" ] && [ "$CHECK_EXISTING" = false ]; then
|
||||||
|
echo -e "${YELLOW}⚠ No local migrations/versions directory for $service, skipping...${NC}"
|
||||||
|
log_message "WARNING" "No local migrations/versions directory for $service"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find a running pod
|
||||||
|
echo -e "${YELLOW}Finding $service pod in namespace $NAMESPACE...${NC}"
|
||||||
|
POD_NAME=$(get_running_pod "$service")
|
||||||
|
if [ -z "$POD_NAME" ]; then
|
||||||
|
echo -e "${RED}✗ No running pod found for $service. Skipping...${NC}"
|
||||||
|
log_message "ERROR" "No running pod found for $service"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (pod not found)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Found pod: $POD_NAME${NC}"
|
||||||
|
log_message "INFO" "Found pod $POD_NAME for $service"
|
||||||
|
|
||||||
|
# Check container availability
|
||||||
|
CONTAINER="${service}-${CONTAINER_SUFFIX}"
|
||||||
|
if ! kubectl get pod -n "$NAMESPACE" "$POD_NAME" -o jsonpath='{.spec.containers[*].name}' | grep -qw "$CONTAINER"; then
|
||||||
|
echo -e "${RED}✗ Container $CONTAINER not found in pod $POD_NAME, skipping${NC}"
|
||||||
|
log_message "ERROR" "Container $CONTAINER not found in pod $POD_NAME for $service"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (container not found)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify database connectivity
|
||||||
|
if [ "$SKIP_DB_CHECK" = false ]; then
|
||||||
|
echo -e "${YELLOW}Verifying database connectivity using $db_env_var...${NC}"
|
||||||
|
# Check if asyncpg is installed
|
||||||
|
ASYNCPG_CHECK=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "python3 -c \"import asyncpg; print('asyncpg OK')\" 2>/dev/null" || echo "asyncpg MISSING")
|
||||||
|
if [[ "$ASYNCPG_CHECK" != "asyncpg OK" ]]; then
|
||||||
|
echo -e "${YELLOW}Installing asyncpg...${NC}"
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "python3 -m pip install --quiet asyncpg" 2>>"$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for database URL
|
||||||
|
DB_URL_CHECK=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "env | grep $db_env_var" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$DB_URL_CHECK" ]; then
|
||||||
|
echo -e "${RED}✗ Environment variable $db_env_var not found in pod $POD_NAME${NC}"
|
||||||
|
echo -e "${YELLOW}Available environment variables:${NC}"
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "env" 2>>"$LOG_FILE" | grep -i "database" || echo "No database-related variables found"
|
||||||
|
log_message "ERROR" "Environment variable $db_env_var not found for $service in pod $POD_NAME"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (missing $db_env_var)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Log redacted database URL for debugging
|
||||||
|
DB_URL=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "echo \$"$db_env_var"" 2>/dev/null | sed 's/\(password=\)[^@]*/\1[REDACTED]/')
|
||||||
|
log_message "INFO" "Using database URL for $service: $DB_URL"
|
||||||
|
|
||||||
|
# Perform async database connectivity check
|
||||||
|
DB_CHECK_OUTPUT=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH python3 -c \"import asyncio; from sqlalchemy.ext.asyncio import create_async_engine; async def check_db(): engine = create_async_engine(os.getenv('$db_env_var')); async with engine.connect() as conn: pass; await engine.dispose(); print('DB OK'); asyncio.run(check_db())\" 2>&1" || echo "DB ERROR")
|
||||||
|
if [[ "$DB_CHECK_OUTPUT" == *"DB OK"* ]]; then
|
||||||
|
echo -e "${GREEN}✓ Database connection verified${NC}"
|
||||||
|
log_message "INFO" "Database connection verified for $service"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Database connection failed for $service${NC}"
|
||||||
|
echo -e "${YELLOW}Error details: $DB_CHECK_OUTPUT${NC}"
|
||||||
|
log_message "ERROR" "Database connection failed for $service: $DB_CHECK_OUTPUT"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (database connection failed)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Skipping database connectivity check (--skip-db-check)${NC}"
|
||||||
|
log_message "INFO" "Skipped database connectivity check for $service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reset alembic version tracking
|
||||||
|
echo -e "${YELLOW}Resetting alembic version tracking...${NC}"
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH alembic downgrade base" 2>&1 | tee -a "$LOG_FILE" | grep -v "^INFO" || true
|
||||||
|
log_message "INFO" "Attempted alembic downgrade for $service"
|
||||||
|
|
||||||
|
# Option 1: Complete database schema reset using CASCADE
|
||||||
|
echo -e "${YELLOW}Performing complete database schema reset...${NC}"
|
||||||
|
SCHEMA_DROP_RESULT=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH python3 << 'EOFPYTHON'
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def reset_database():
|
||||||
|
try:
|
||||||
|
engine = create_async_engine(os.getenv('$db_env_var'))
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Drop and recreate public schema - cleanest approach
|
||||||
|
await conn.execute(text('DROP SCHEMA IF EXISTS public CASCADE'))
|
||||||
|
await conn.execute(text('CREATE SCHEMA public'))
|
||||||
|
await conn.execute(text('GRANT ALL ON SCHEMA public TO PUBLIC'))
|
||||||
|
await engine.dispose()
|
||||||
|
print('SUCCESS: Database schema reset complete')
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {str(e)}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
exit(asyncio.run(reset_database()))
|
||||||
|
EOFPYTHON
|
||||||
|
" 2>&1)
|
||||||
|
|
||||||
|
echo "$SCHEMA_DROP_RESULT" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
if echo "$SCHEMA_DROP_RESULT" | grep -q "SUCCESS"; then
|
||||||
|
echo -e "${GREEN}✓ Database schema reset successfully${NC}"
|
||||||
|
log_message "INFO" "Database schema reset for $service"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Database schema reset failed${NC}"
|
||||||
|
echo -e "${YELLOW}Error details:${NC}"
|
||||||
|
echo "$SCHEMA_DROP_RESULT"
|
||||||
|
log_message "ERROR" "Database schema reset failed for $service: $SCHEMA_DROP_RESULT"
|
||||||
|
|
||||||
|
# Try alternative approach: Drop individual tables from database (not just models)
|
||||||
|
echo -e "${YELLOW}Attempting alternative: dropping all existing tables individually...${NC}"
|
||||||
|
TABLE_DROP_RESULT=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH python3 << 'EOFPYTHON'
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def drop_all_tables():
|
||||||
|
try:
|
||||||
|
engine = create_async_engine(os.getenv('$db_env_var'))
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Get all tables from database
|
||||||
|
result = await conn.execute(text(\"\"\"
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
\"\"\"))
|
||||||
|
tables = [row[0] for row in result]
|
||||||
|
|
||||||
|
# Drop each table
|
||||||
|
for table in tables:
|
||||||
|
await conn.execute(text(f'DROP TABLE IF EXISTS \"{table}\" CASCADE'))
|
||||||
|
|
||||||
|
print(f'SUCCESS: Dropped {len(tables)} tables: {tables}')
|
||||||
|
await engine.dispose()
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f'ERROR: {str(e)}')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
exit(asyncio.run(drop_all_tables()))
|
||||||
|
EOFPYTHON
|
||||||
|
" 2>&1)
|
||||||
|
|
||||||
|
echo "$TABLE_DROP_RESULT" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
if echo "$TABLE_DROP_RESULT" | grep -q "SUCCESS"; then
|
||||||
|
echo -e "${GREEN}✓ All tables dropped successfully${NC}"
|
||||||
|
log_message "INFO" "All tables dropped for $service"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Failed to drop tables${NC}"
|
||||||
|
echo -e "${YELLOW}Error details:${NC}"
|
||||||
|
echo "$TABLE_DROP_RESULT"
|
||||||
|
log_message "ERROR" "Failed to drop tables for $service: $TABLE_DROP_RESULT"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (database cleanup failed)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify database is empty
|
||||||
|
echo -e "${YELLOW}Verifying database is clean...${NC}"
|
||||||
|
VERIFY_RESULT=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH python3 << 'EOFPYTHON'
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
async def verify_empty():
|
||||||
|
engine = create_async_engine(os.getenv('$db_env_var'))
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(text(\"\"\"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
\"\"\"))
|
||||||
|
count = result.scalar()
|
||||||
|
print(f'Tables remaining: {count}')
|
||||||
|
await engine.dispose()
|
||||||
|
return count
|
||||||
|
|
||||||
|
exit(asyncio.run(verify_empty()))
|
||||||
|
EOFPYTHON
|
||||||
|
" 2>&1)
|
||||||
|
|
||||||
|
echo "$VERIFY_RESULT" >> "$LOG_FILE"
|
||||||
|
echo -e "${BLUE}$VERIFY_RESULT${NC}"
|
||||||
|
|
||||||
|
# Initialize alembic version table after schema reset
|
||||||
|
echo -e "${YELLOW}Initializing alembic version tracking...${NC}"
|
||||||
|
ALEMBIC_INIT_OUTPUT=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH alembic stamp base" 2>&1)
|
||||||
|
ALEMBIC_INIT_EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo "$ALEMBIC_INIT_OUTPUT" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
if [ $ALEMBIC_INIT_EXIT_CODE -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Alembic version tracking initialized${NC}"
|
||||||
|
log_message "INFO" "Alembic version tracking initialized for $service"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Alembic initialization warning (may be normal)${NC}"
|
||||||
|
log_message "WARNING" "Alembic initialization for $service: $ALEMBIC_INIT_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove old migration files in pod
|
||||||
|
echo -e "${YELLOW}Removing old migration files in pod...${NC}"
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "rm -rf /app/migrations/versions/*.py /app/migrations/versions/__pycache__" 2>>"$LOG_FILE" || log_message "WARNING" "Failed to remove old migration files for $service"
|
||||||
|
|
||||||
|
# Ensure dependencies
|
||||||
|
echo -e "${YELLOW}Ensuring python-dateutil and asyncpg are installed...${NC}"
|
||||||
|
kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "python3 -m pip install --quiet python-dateutil asyncpg" 2>>"$LOG_FILE"
|
||||||
|
|
||||||
|
# Generate migration
|
||||||
|
echo -e "${YELLOW}Running alembic autogenerate in pod...${NC}"
|
||||||
|
MIGRATION_TIMESTAMP=$(date +%Y%m%d_%H%M)
|
||||||
|
MIGRATION_OUTPUT=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH python3 -m alembic revision --autogenerate -m \"initial_schema_$MIGRATION_TIMESTAMP\"" 2>&1)
|
||||||
|
MIGRATION_EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo "$MIGRATION_OUTPUT" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
if [ $MIGRATION_EXIT_CODE -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Migration generated in pod${NC}"
|
||||||
|
log_message "INFO" "Migration generated for $service"
|
||||||
|
|
||||||
|
# Copy migration file
|
||||||
|
MIGRATION_FILE=$(kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "ls -t /app/migrations/versions/*.py 2>/dev/null | head -1" || echo "")
|
||||||
|
if [ -z "$MIGRATION_FILE" ]; then
|
||||||
|
echo -e "${RED}✗ No migration file found in pod${NC}"
|
||||||
|
log_message "ERROR" "No migration file generated for $service"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (no file generated)")
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
MIGRATION_FILENAME=$(basename "$MIGRATION_FILE")
|
||||||
|
mkdir -p "services/$service_dir/migrations/versions"
|
||||||
|
|
||||||
|
# Copy file with better error handling
|
||||||
|
echo -e "${YELLOW}Copying migration file from pod...${NC}"
|
||||||
|
CP_OUTPUT=$(kubectl cp -n "$NAMESPACE" "$POD_NAME:$MIGRATION_FILE" "services/$service_dir/migrations/versions/$MIGRATION_FILENAME" -c "$CONTAINER" 2>&1)
|
||||||
|
CP_EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo "$CP_OUTPUT" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Verify the file was actually copied
|
||||||
|
if [ $CP_EXIT_CODE -eq 0 ] && [ -f "services/$service_dir/migrations/versions/$MIGRATION_FILENAME" ]; then
|
||||||
|
LOCAL_FILE_SIZE=$(wc -c < "services/$service_dir/migrations/versions/$MIGRATION_FILENAME" | tr -d ' ')
|
||||||
|
|
||||||
|
if [ "$LOCAL_FILE_SIZE" -gt 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Migration file copied: $MIGRATION_FILENAME ($LOCAL_FILE_SIZE bytes)${NC}"
|
||||||
|
log_message "INFO" "Copied $MIGRATION_FILENAME for $service ($LOCAL_FILE_SIZE bytes)"
|
||||||
|
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
||||||
|
|
||||||
|
# Validate migration content
|
||||||
|
echo -e "${YELLOW}Validating migration content...${NC}"
|
||||||
|
if grep -E "op\.(create_table|add_column|create_index|alter_column|drop_table|drop_column|create_foreign_key)" "services/$service_dir/migrations/versions/$MIGRATION_FILENAME" >/dev/null; then
|
||||||
|
echo -e "${GREEN}✓ Migration contains schema operations${NC}"
|
||||||
|
log_message "INFO" "Migration contains schema operations for $service"
|
||||||
|
elif grep -q "pass" "services/$service_dir/migrations/versions/$MIGRATION_FILENAME" && grep -q "def upgrade()" "services/$service_dir/migrations/versions/$MIGRATION_FILENAME"; then
|
||||||
|
echo -e "${YELLOW}⚠ WARNING: Migration is empty (no schema changes detected)${NC}"
|
||||||
|
echo -e "${YELLOW}⚠ This usually means tables already exist in database matching the models${NC}"
|
||||||
|
log_message "WARNING" "Empty migration generated for $service - possible database cleanup issue"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Migration file created${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display summary
|
||||||
|
echo -e "${BLUE}Migration summary:${NC}"
|
||||||
|
grep -E "^def (upgrade|downgrade)" "services/$service_dir/migrations/versions/$MIGRATION_FILENAME" | head -2
|
||||||
|
echo -e "${BLUE}Operations:${NC}"
|
||||||
|
grep "op\." "services/$service_dir/migrations/versions/$MIGRATION_FILENAME" | head -5 || echo " (none found)"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Migration file is empty (0 bytes)${NC}"
|
||||||
|
log_message "ERROR" "Migration file is empty for $service"
|
||||||
|
rm -f "services/$service_dir/migrations/versions/$MIGRATION_FILENAME"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (empty file)")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Failed to copy migration file${NC}"
|
||||||
|
echo -e "${YELLOW}kubectl cp exit code: $CP_EXIT_CODE${NC}"
|
||||||
|
echo -e "${YELLOW}kubectl cp output: $CP_OUTPUT${NC}"
|
||||||
|
log_message "ERROR" "Failed to copy migration file for $service: $CP_OUTPUT"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (copy failed)")
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Failed to generate migration${NC}"
|
||||||
|
log_message "ERROR" "Failed to generate migration for $service"
|
||||||
|
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||||
|
FAILED_SERVICES+=("$service (generation failed)")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process services sequentially
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
process_service "$service"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}Summary${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${GREEN}✓ Successful: $SUCCESS_COUNT services${NC}"
|
||||||
|
echo -e "${RED}✗ Failed: $FAILED_COUNT services${NC}"
|
||||||
|
|
||||||
|
if [ "$FAILED_COUNT" -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}Failed services:${NC}"
|
||||||
|
for failed_service in "${FAILED_SERVICES[@]}"; do
|
||||||
|
echo -e "${RED} - $failed_service${NC}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Backup location: $BACKUP_DIR${NC}"
|
||||||
|
echo -e "${YELLOW}Log file: $LOG_FILE${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Apply migrations if requested
|
||||||
|
if [ "$APPLY_MIGRATIONS" = true ] && [ "$DRY_RUN" = false ] && [ "$SUCCESS_COUNT" -gt 0 ]; then
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE}Applying Migrations${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
APPLIED_COUNT=0
|
||||||
|
APPLY_FAILED_COUNT=0
|
||||||
|
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
service_dir=$(echo "$service" | tr '-' '_')
|
||||||
|
local db_env_var=$(echo "$service" | tr '[:lower:]-' '[:upper:]_')_DATABASE_URL
|
||||||
|
if [ ! -d "services/$service_dir/migrations/versions" ] || [ -z "$(ls services/$service_dir/migrations/versions/*.py 2>/dev/null)" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}Applying migrations for: $service${NC}"
|
||||||
|
POD_NAME=$(get_running_pod "$service")
|
||||||
|
if [ -z "$POD_NAME" ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Pod not found for $service, skipping...${NC}"
|
||||||
|
log_message "WARNING" "No running pod found for $service during migration application"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONTAINER="${service}-${CONTAINER_SUFFIX}"
|
||||||
|
if ! kubectl get pod -n "$NAMESPACE" "$POD_NAME" -o jsonpath='{.spec.containers[*].name}' | grep -qw "$CONTAINER"; then
|
||||||
|
echo -e "${RED}✗ Container $CONTAINER not found in pod $POD_NAME, skipping${NC}"
|
||||||
|
log_message "ERROR" "Container $CONTAINER not found in pod $POD_NAME for $service"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kubectl exec -n "$NAMESPACE" "$POD_NAME" -c "$CONTAINER" -- sh -c "cd /app && PYTHONPATH=/app:/app/shared:\$PYTHONPATH alembic upgrade head" 2>>"$LOG_FILE"; then
|
||||||
|
echo -e "${GREEN}✓ Migrations applied successfully for $service${NC}"
|
||||||
|
log_message "INFO" "Migrations applied for $service"
|
||||||
|
APPLIED_COUNT=$((APPLIED_COUNT + 1))
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Failed to apply migrations for $service${NC}"
|
||||||
|
log_message "ERROR" "Failed to apply migrations for $service"
|
||||||
|
APPLY_FAILED_COUNT=$((APPLY_FAILED_COUNT + 1))
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${BLUE}Migration Application Summary:${NC}"
|
||||||
|
echo -e "${GREEN}✓ Applied: $APPLIED_COUNT services${NC}"
|
||||||
|
echo -e "${RED}✗ Failed: $APPLY_FAILED_COUNT services${NC}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up temporary files
|
||||||
|
rm -f /tmp/*_migration.log /tmp/*_downgrade.log /tmp/*_apply.log 2>/dev/null || true
|
||||||
|
log_message "INFO" "Cleaned up temporary files"
|
||||||
|
|
||||||
|
echo -e "${BLUE}Next steps:${NC}"
|
||||||
|
echo -e "${YELLOW}1. Review the generated migrations in services/*/migrations/versions/${NC}"
|
||||||
|
echo -e "${YELLOW}2. Compare with the backup in $BACKUP_DIR${NC}"
|
||||||
|
echo -e "${YELLOW}3. Check logs in $LOG_FILE for details${NC}"
|
||||||
|
echo -e "${YELLOW}4. Test migrations by applying them:${NC}"
|
||||||
|
echo -e " ${GREEN}kubectl exec -n $NAMESPACE -it <pod-name> -c <service>-${CONTAINER_SUFFIX} -- alembic upgrade head${NC}"
|
||||||
|
echo -e "${YELLOW}5. Verify tables were created:${NC}"
|
||||||
|
echo -e " ${GREEN}kubectl exec -n $NAMESPACE -it <pod-name> -c <service>-${CONTAINER_SUFFIX} -- python3 -c \"${NC}"
|
||||||
|
echo -e " ${GREEN}import asyncio; from sqlalchemy.ext.asyncio import create_async_engine; from sqlalchemy import inspect; async def check_tables(): engine = create_async_engine(os.getenv('<SERVICE>_DATABASE_URL')); async with engine.connect() as conn: print(inspect(conn).get_table_names()); await engine.dispose(); asyncio.run(check_tables())${NC}"
|
||||||
|
echo -e " ${GREEN}\"${NC}"
|
||||||
|
echo -e "${YELLOW}6. If issues occur, restore from backup:${NC}"
|
||||||
|
echo -e " ${GREEN}cp -r $BACKUP_DIR/*/versions/* services/*/migrations/versions/${NC}"
|
||||||
|
echo ""
|
||||||
@@ -1,40 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Seed script to create the PILOT2025 coupon for the pilot customer program.
|
Seed script to create the PILOT2025 coupon for the pilot customer program.
|
||||||
This coupon provides 3 months (90 days) free trial extension for the first 20 customers.
|
This coupon provides 3 months (90 days) free trial extension for the first 20 customers.
|
||||||
|
|
||||||
|
This script runs as a Kubernetes job inside the tenant-service container.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python /app/services/tenant/scripts/seed_pilot_coupon.py
|
||||||
|
|
||||||
|
Environment Variables Required:
|
||||||
|
TENANT_DATABASE_URL - PostgreSQL connection string for tenant database
|
||||||
|
LOG_LEVEL - Logging level (default: INFO)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
# Add project root to path
|
# Add app to path
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..')))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy import select
|
||||||
|
import structlog
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.models.coupon import CouponModel
|
from app.models.coupon import CouponModel
|
||||||
from shared.database import get_db
|
|
||||||
|
# Configure logging
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.dev.ConsoleRenderer()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
def seed_pilot_coupon(db: Session):
|
async def seed_pilot_coupon(db: AsyncSession):
|
||||||
"""Create or update the PILOT2025 coupon"""
|
"""Create or update the PILOT2025 coupon"""
|
||||||
|
|
||||||
coupon_code = "PILOT2025"
|
coupon_code = "PILOT2025"
|
||||||
|
|
||||||
|
logger.info("=" * 80)
|
||||||
|
logger.info("🎫 Seeding PILOT2025 Coupon")
|
||||||
|
logger.info("=" * 80)
|
||||||
|
|
||||||
# Check if coupon already exists
|
# Check if coupon already exists
|
||||||
existing_coupon = db.query(CouponModel).filter(
|
result = await db.execute(
|
||||||
CouponModel.code == coupon_code
|
select(CouponModel).where(CouponModel.code == coupon_code)
|
||||||
).first()
|
)
|
||||||
|
existing_coupon = result.scalars().first()
|
||||||
|
|
||||||
if existing_coupon:
|
if existing_coupon:
|
||||||
print(f"✓ Coupon {coupon_code} already exists")
|
logger.info(
|
||||||
print(f" Current redemptions: {existing_coupon.current_redemptions}/{existing_coupon.max_redemptions}")
|
"Coupon already exists",
|
||||||
print(f" Active: {existing_coupon.active}")
|
code=coupon_code,
|
||||||
print(f" Valid from: {existing_coupon.valid_from}")
|
current_redemptions=existing_coupon.current_redemptions,
|
||||||
print(f" Valid until: {existing_coupon.valid_until}")
|
max_redemptions=existing_coupon.max_redemptions,
|
||||||
|
active=existing_coupon.active,
|
||||||
|
valid_from=existing_coupon.valid_from,
|
||||||
|
valid_until=existing_coupon.valid_until
|
||||||
|
)
|
||||||
return existing_coupon
|
return existing_coupon
|
||||||
|
|
||||||
# Create new coupon
|
# Create new coupon
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
valid_until = now + timedelta(days=180) # Valid for 6 months
|
valid_until = now + timedelta(days=180) # Valid for 6 months
|
||||||
|
|
||||||
coupon = CouponModel(
|
coupon = CouponModel(
|
||||||
@@ -56,47 +93,86 @@ def seed_pilot_coupon(db: Session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
db.add(coupon)
|
db.add(coupon)
|
||||||
db.commit()
|
await db.commit()
|
||||||
db.refresh(coupon)
|
await db.refresh(coupon)
|
||||||
|
|
||||||
print(f"✓ Successfully created coupon: {coupon_code}")
|
logger.info("=" * 80)
|
||||||
print(f" Type: Trial Extension")
|
logger.info(
|
||||||
print(f" Value: 90 days (3 months)")
|
"✅ Successfully created coupon",
|
||||||
print(f" Max redemptions: 20")
|
code=coupon_code,
|
||||||
print(f" Valid from: {coupon.valid_from}")
|
type="Trial Extension",
|
||||||
print(f" Valid until: {coupon.valid_until}")
|
value="90 days (3 months)",
|
||||||
print(f" ID: {coupon.id}")
|
max_redemptions=20,
|
||||||
|
valid_from=coupon.valid_from,
|
||||||
|
valid_until=coupon.valid_until,
|
||||||
|
id=str(coupon.id)
|
||||||
|
)
|
||||||
|
logger.info("=" * 80)
|
||||||
|
|
||||||
return coupon
|
return coupon
|
||||||
|
|
||||||
|
|
||||||
def main():
|
async def main():
|
||||||
"""Main execution function"""
|
"""Main execution function"""
|
||||||
print("=" * 60)
|
|
||||||
print("Seeding PILOT2025 Coupon for Pilot Customer Program")
|
logger.info("Pilot Coupon Seeding Script Starting")
|
||||||
print("=" * 60)
|
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
|
||||||
print()
|
|
||||||
|
# Get database URL from environment
|
||||||
|
database_url = os.getenv("TENANT_DATABASE_URL") or os.getenv("DATABASE_URL")
|
||||||
|
if not database_url:
|
||||||
|
logger.error("❌ TENANT_DATABASE_URL or DATABASE_URL environment variable must be set")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Convert to async URL if needed
|
||||||
|
if database_url.startswith("postgresql://"):
|
||||||
|
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
|
||||||
|
logger.info("Connecting to tenant database")
|
||||||
|
|
||||||
|
# Create engine and session
|
||||||
|
engine = create_async_engine(
|
||||||
|
database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session = sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get database session
|
async with async_session() as session:
|
||||||
db = next(get_db())
|
await seed_pilot_coupon(session)
|
||||||
|
|
||||||
# Seed the coupon
|
logger.info("")
|
||||||
seed_pilot_coupon(db)
|
logger.info("🎉 Success! PILOT2025 coupon is ready.")
|
||||||
|
logger.info("")
|
||||||
|
logger.info("Coupon Details:")
|
||||||
|
logger.info(" Code: PILOT2025")
|
||||||
|
logger.info(" Type: Trial Extension")
|
||||||
|
logger.info(" Value: 90 days (3 months)")
|
||||||
|
logger.info(" Max Redemptions: 20")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
print()
|
return 0
|
||||||
print("=" * 60)
|
|
||||||
print("✓ Coupon seeding completed successfully!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"✗ Error seeding coupon: {e}")
|
logger.error("=" * 80)
|
||||||
import traceback
|
logger.error("❌ Pilot Coupon Seeding Failed")
|
||||||
traceback.print_exc()
|
logger.error("=" * 80)
|
||||||
sys.exit(1)
|
logger.error("Error: %s", str(e))
|
||||||
|
logger.error("", exc_info=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
exit_code = asyncio.run(main())
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|||||||
Reference in New Issue
Block a user