Add subcription feature 2

This commit is contained in:
Urtzi Alfaro
2026-01-14 13:15:48 +01:00
parent 6ddf608d37
commit a4c3b7da3f
32 changed files with 4240 additions and 965 deletions

View File

@@ -70,6 +70,52 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
return;
}
// Perform client-side validation before sending to Stripe
if (!billingDetails.name.trim()) {
setError('El nombre del titular es obligatorio');
onPaymentError('El nombre del titular es obligatorio');
setLoading(false);
return;
}
if (!billingDetails.email.trim()) {
setError('El correo electrónico es obligatorio');
onPaymentError('El correo electrónico es obligatorio');
setLoading(false);
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(billingDetails.email)) {
setError('Por favor, ingrese un correo electrónico válido');
onPaymentError('Por favor, ingrese un correo electrónico válido');
setLoading(false);
return;
}
// Validate required address fields
if (!billingDetails.address.line1.trim()) {
setError('La dirección es obligatoria');
onPaymentError('La dirección es obligatoria');
setLoading(false);
return;
}
if (!billingDetails.address.city.trim()) {
setError('La ciudad es obligatoria');
onPaymentError('La ciudad es obligatoria');
setLoading(false);
return;
}
if (!billingDetails.address.postal_code.trim()) {
setError('El código postal es obligatorio');
onPaymentError('El código postal es obligatorio');
setLoading(false);
return;
}
setLoading(true);
setError(null);
@@ -126,6 +172,16 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
setCardComplete(event.complete);
};
// Add a cleanup function to ensure loading state is reset when component unmounts
useEffect(() => {
return () => {
// Ensure loading is reset when component unmounts to prevent stuck states
if (loading) {
setLoading(false);
}
};
}, []);
return (
<Card className={`p-6 ${className}`}>
<div className="text-center mb-6">

View File

@@ -75,6 +75,18 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const [errors, setErrors] = useState<Partial<SimpleUserRegistration>>({});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false); // Local loading state for 3DS flow
// State for pending subscription data (needed for post-3DS completion)
const [pendingSubscriptionData, setPendingSubscriptionData] = useState<{
customer_id: string;
plan_id: string;
payment_method_id: string;
trial_period_days?: number;
user_id: string;
billing_interval: string;
setup_intent_id?: string;
} | null>(null);
const { register, registerWithSubscription } = useAuthActions();
const isLoading = useAuthLoading();
@@ -144,14 +156,76 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
// directly to backend via secure API calls.
// Clean up any old registration_progress data on mount (security fix)
// Also check for Stripe 3DS redirect parameters
useEffect(() => {
try {
localStorage.removeItem('registration_progress');
localStorage.removeItem('wizardState'); // Clean up wizard state too
} catch (err) {
console.error('Error cleaning up old localStorage data:', err);
// Prevent multiple executions of 3DS redirect handling
const hasProcessedRedirect = sessionStorage.getItem('3ds_redirect_processed');
if (hasProcessedRedirect) {
return;
}
}, []);
// Clean up old data
localStorage.removeItem('registration_progress');
localStorage.removeItem('wizardState');
// Check if this is a Stripe redirect after 3DS authentication
const urlParams = new URLSearchParams(window.location.search);
const setupIntent = urlParams.get('setup_intent');
const redirectStatus = urlParams.get('redirect_status');
if (setupIntent && redirectStatus) {
console.log('3DS redirect detected:', { setupIntent, redirectStatus });
// Mark as processed
sessionStorage.setItem('3ds_redirect_processed', 'true');
// Clean the URL immediately
const cleanUrl = window.location.pathname;
window.history.replaceState({}, '', cleanUrl);
if (redirectStatus === 'succeeded') {
// Retrieve pending registration data (NEW ARCHITECTURE)
const pendingDataStr = sessionStorage.getItem('pending_registration_data');
if (pendingDataStr) {
try {
setLoading(true);
const pendingData = JSON.parse(pendingDataStr);
console.log('3DS redirect detected - completing registration (NEW ARCHITECTURE)');
// Complete the registration (create user and subscription)
completeRegistrationAfterSetupIntent(setupIntent, pendingData);
} catch (err) {
console.error('Error parsing pending registration data:', err);
showToast.error('Error al procesar datos de registro', {
title: 'Error'
});
setLoading(false);
}
} else {
console.warn('No pending registration data found after 3DS redirect');
showToast.error('No se encontraron datos de registro pendientes', {
title: 'Error'
});
setLoading(false);
}
} else if (redirectStatus === 'failed') {
sessionStorage.removeItem('pending_registration_data');
showToast.error('La autenticación 3D Secure ha fallado', {
title: 'Error de autenticación'
});
setCurrentStep('payment');
setLoading(false);
}
// Clear the flag
setTimeout(() => {
sessionStorage.removeItem('3ds_redirect_processed');
}, 1000);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const validateForm = (): boolean => {
const newErrors: Partial<SimpleUserRegistration> = {};
@@ -219,31 +293,71 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
const handleRegistrationSubmit = async (paymentMethodId?: string) => {
try {
setLoading(true);
const registrationData = {
full_name: formData.full_name,
email: formData.email,
password: formData.password,
tenant_name: 'Default Bakery', // Default value since we're not collecting it
tenant_name: 'Default Bakery',
subscription_plan: selectedPlan,
billing_cycle: billingCycle, // Add billing cycle selection
billing_cycle: billingCycle,
payment_method_id: paymentMethodId,
// Include coupon code if pilot customer
coupon_code: isPilot ? couponCode : undefined,
// Include consent data
terms_accepted: formData.acceptTerms,
privacy_accepted: formData.acceptTerms,
marketing_consent: formData.marketingConsent,
analytics_consent: formData.analyticsConsent,
// NEW: Include billing address data for subscription creation
address: formData.address,
postal_code: formData.postal_code,
city: formData.city,
country: formData.country,
};
// Use the new registration endpoint with subscription creation
await registerWithSubscription(registrationData);
// Call registration endpoint
const response = await registerWithSubscription(registrationData);
// Validate response exists
if (!response) {
throw new Error('Registration failed: No response from server');
}
// NEW ARCHITECTURE: Check if SetupIntent verification is required
if (response.requires_action && response.client_secret) {
console.log('SetupIntent verification required (NEW ARCHITECTURE - no user created yet):', {
action_type: response.action_type,
setup_intent_id: response.setup_intent_id,
customer_id: response.customer_id,
email: response.email
});
// Store pending registration data for post-3DS completion
const pendingData = {
email: registrationData.email,
password: registrationData.password,
full_name: registrationData.full_name,
setup_intent_id: response.setup_intent_id!,
plan_id: response.plan_id!,
payment_method_id: paymentMethodId!,
billing_interval: billingCycle,
coupon_code: registrationData.coupon_code,
customer_id: response.customer_id!,
payment_customer_id: response.payment_customer_id!,
trial_period_days: response.trial_period_days ?? undefined,
client_secret: response.client_secret!
};
setPendingSubscriptionData(pendingData);
sessionStorage.setItem('pending_registration_data', JSON.stringify(pendingData));
setLoading(false);
// Handle SetupIntent confirmation (with possible 3DS)
await handleSetupIntentConfirmation(response.client_secret, paymentMethodId!, pendingData);
return;
}
// Subscription created successfully without 3DS
const successMessage = isPilot
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
@@ -251,9 +365,12 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
showToast.success(t('auth:register.registering', successMessage), {
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
});
onSuccess?.();
} catch (err) {
showToast.error(error || t('auth:register.register_button', 'No se pudo crear la account. Verifica que el email no esté en uso.'), {
setLoading(false);
const errorMessage = err instanceof Error ? err.message : 'No se pudo crear la cuenta';
showToast.error(errorMessage, {
title: t('auth:alerts.error_create', 'Error al crear la cuenta')
});
}
@@ -269,6 +386,141 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
});
};
// Handle SetupIntent confirmation with 3DS support
const handleSetupIntentConfirmation = async (
clientSecret: string,
paymentMethodId: string,
pendingData: {
email: string;
password: string;
full_name: string;
setup_intent_id: string;
plan_id: string;
payment_method_id: string;
billing_interval: string;
coupon_code?: string;
customer_id: string;
payment_customer_id: string;
trial_period_days?: number;
client_secret: string;
}
) => {
try {
setLoading(true);
const stripeInstance = await stripePromise;
if (!stripeInstance) {
throw new Error('Stripe.js has not loaded correctly.');
}
console.log('Confirming SetupIntent (NEW ARCHITECTURE - no user created yet):', { clientSecret });
// Confirm the SetupIntent with 3DS authentication if needed
const returnUrl = `${window.location.origin}${window.location.pathname}`;
const { error: stripeError, setupIntent } = await stripeInstance.confirmCardSetup(clientSecret, {
payment_method: paymentMethodId,
return_url: returnUrl
});
if (stripeError) {
console.error('SetupIntent confirmation error:', stripeError);
showToast.error(stripeError.message || 'Error en la verificación del método de pago', {
title: 'Error de autenticación'
});
setLoading(false);
return;
}
// Check if setup intent succeeded (no 3DS required, or 3DS completed in modal)
if (setupIntent && setupIntent.status === 'succeeded') {
console.log('SetupIntent succeeded, completing registration (NEW ARCHITECTURE - creating user now)');
await completeRegistrationAfterSetupIntent(setupIntent.id, pendingData);
} else if (setupIntent && setupIntent.status === 'requires_action') {
// 3DS redirect happened - the completion will be handled in URL redirect handler
console.log('SetupIntent requires 3DS redirect, waiting for return...');
// Don't set loading to false - keep spinner while redirecting
} else {
console.error('Unexpected SetupIntent status:', setupIntent?.status);
showToast.error(`Estado inesperado: ${setupIntent?.status}`, {
title: 'Error'
});
setLoading(false);
}
} catch (err) {
console.error('Error during SetupIntent confirmation:', err);
const errorMessage = err instanceof Error ? err.message : 'Error durante la verificación';
showToast.error(errorMessage, {
title: 'Error de autenticación'
});
setLoading(false);
}
};
// Complete subscription creation after SetupIntent confirmation
// Complete registration after SetupIntent confirmation (NEW ARCHITECTURE)
const completeRegistrationAfterSetupIntent = async (
setupIntentId: string,
pendingData: {
email: string;
password: string;
full_name: string;
setup_intent_id: string;
plan_id: string;
payment_method_id: string;
billing_interval: string;
coupon_code?: string;
customer_id: string;
payment_customer_id: string;
trial_period_days?: number;
client_secret: string;
}
) => {
try {
console.log('Completing registration after SetupIntent confirmation (NEW ARCHITECTURE - creating user now)');
// Call backend to complete registration (create user and subscription)
const completionData = {
email: pendingData.email,
password: pendingData.password,
full_name: pendingData.full_name,
setup_intent_id: setupIntentId,
plan_id: pendingData.plan_id,
payment_method_id: pendingData.payment_method_id,
billing_interval: pendingData.billing_interval,
coupon_code: pendingData.coupon_code
};
const response = await completeRegistrationAfterSetupIntent(completionData);
if (response.access_token) {
console.log('Registration completed successfully - user created and authenticated');
const successMessage = isPilot
? '¡Bienvenido al programa piloto! Tu cuenta ha sido creada con 3 meses gratis.'
: '¡Bienvenido! Tu cuenta ha sido creada correctamente.';
showToast.success(t('auth:register.registering', successMessage), {
title: t('auth:alerts.success_create', 'Cuenta creada exitosamente')
});
// Clear pending registration data
sessionStorage.removeItem('pending_registration_data');
setPendingSubscriptionData(null);
onSuccess?.();
} else {
throw new Error('Registration completion failed: No access token received');
}
} catch (err) {
setLoading(false);
const errorMessage = err instanceof Error ? err.message : 'Error completando el registro';
showToast.error(errorMessage, {
title: 'Error de registro'
});
console.error('Error completing registration:', err);
}
};
const handleInputChange = (field: keyof SimpleUserRegistration) => (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setFormData(prev => ({ ...prev, [field]: value }));