Add subcription feature 3
This commit is contained in:
378
frontend/src/components/domain/auth/BasicInfoStep.tsx
Normal file
378
frontend/src/components/domain/auth/BasicInfoStep.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Basic Info Step Component
|
||||
* First step of registration - collects user basic information
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Checkbox } from '../../ui';
|
||||
import { PasswordCriteria } from '../../ui/PasswordCriteria';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { validateEmail } from '../../../utils/validation';
|
||||
|
||||
interface BasicInfoStepProps {
|
||||
formData: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
marketingConsent: boolean;
|
||||
analyticsConsent: boolean;
|
||||
firstNameError?: string;
|
||||
lastNameError?: string;
|
||||
emailError?: string;
|
||||
passwordError?: string;
|
||||
confirmPasswordError?: string;
|
||||
};
|
||||
errors: any;
|
||||
updateField: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
isPilot: boolean;
|
||||
isSubmitting?: boolean;
|
||||
onLoginClick?: () => void;
|
||||
}
|
||||
|
||||
export const BasicInfoStep: React.FC<BasicInfoStepProps> = ({
|
||||
formData,
|
||||
errors,
|
||||
updateField,
|
||||
onNext,
|
||||
isPilot,
|
||||
isSubmitting,
|
||||
onLoginClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = React.useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
};
|
||||
|
||||
// Real-time validation for email field
|
||||
const validateEmailField = (email: string) => {
|
||||
if (!email.trim()) {
|
||||
return { isValid: false, error: t('auth:register.email_required', 'Email is required') };
|
||||
}
|
||||
return validateEmail(email);
|
||||
};
|
||||
|
||||
// Real-time validation for password field
|
||||
const validatePasswordField = (password: string) => {
|
||||
if (!password) {
|
||||
return { isValid: false, error: t('auth:register.password_required', 'Password is required') };
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return { isValid: false, error: t('auth:register.password_too_short', 'Password must be at least 8 characters') };
|
||||
}
|
||||
return { isValid: true, error: '' };
|
||||
};
|
||||
|
||||
// Real-time validation for confirm password
|
||||
const validateConfirmPassword = (confirmPassword: string) => {
|
||||
if (!confirmPassword) {
|
||||
return { isValid: false, error: t('auth:register.confirm_password_required', 'Please confirm your password') };
|
||||
}
|
||||
if (confirmPassword !== formData.password) {
|
||||
return { isValid: false, error: t('auth:register.passwords_dont_match', 'Passwords do not match') };
|
||||
}
|
||||
return { isValid: true, error: '' };
|
||||
};
|
||||
|
||||
// Real-time validation for name fields
|
||||
const validateNameField = (name: string, fieldName: string) => {
|
||||
if (!name.trim()) {
|
||||
return { isValid: false, error: t('auth:register.name_required', 'This field is required') };
|
||||
}
|
||||
if (name.length < 2) {
|
||||
return { isValid: false, error: t('auth:register.name_too_short', 'Name must be at least 2 characters') };
|
||||
}
|
||||
return { isValid: true, error: '' };
|
||||
};
|
||||
|
||||
// Calculate password strength
|
||||
const calculatePasswordStrength = (password: string) => {
|
||||
let strength = 0;
|
||||
|
||||
// Length check
|
||||
if (password.length >= 12) strength += 2;
|
||||
else if (password.length >= 8) strength += 1;
|
||||
|
||||
// Character variety checks
|
||||
if (/[A-Z]/.test(password)) strength += 1; // Uppercase
|
||||
if (/[0-9]/.test(password)) strength += 1; // Numbers
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength += 1; // Special chars
|
||||
|
||||
// Determine strength level
|
||||
if (strength <= 2) return t('auth:register.weak', 'Weak');
|
||||
if (strength <= 3) return t('auth:register.medium', 'Medium');
|
||||
if (strength <= 4) return t('auth:register.strong', 'Strong');
|
||||
return t('auth:register.very_strong', 'Very Strong');
|
||||
};
|
||||
|
||||
// Get strength bar color
|
||||
const getStrengthColor = (index: number, password: string) => {
|
||||
const strength = calculatePasswordStrength(password);
|
||||
const strengthValue =
|
||||
strength === t('auth:register.weak', 'Weak') ? 1 :
|
||||
strength === t('auth:register.medium', 'Medium') ? 2 :
|
||||
strength === t('auth:register.strong', 'Strong') ? 3 : 4;
|
||||
|
||||
if (index < strengthValue) {
|
||||
if (strengthValue <= 2) return 'bg-red-500';
|
||||
if (strengthValue === 3) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
return 'bg-gray-200';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="basic-info-step w-full max-w-lg mx-auto">
|
||||
<h2 className="text-xl sm:text-2xl font-bold mb-4 sm:mb-6 text-[var(--text-primary)] text-center sm:text-left">
|
||||
{t('auth:register.basic_info_title', 'Create Your Account')}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{/* First Name and Last Name - Stack on mobile, side by side on larger screens */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium mb-1 text-[var(--text-primary)]">
|
||||
{t('auth:register.first_name', 'First Name')}
|
||||
</label>
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => updateField('firstName', e.target.value)}
|
||||
onBlur={() => {
|
||||
const validation = validateNameField(formData.firstName, 'firstName');
|
||||
if (!validation.isValid) {
|
||||
updateField('firstNameError', validation.error);
|
||||
} else {
|
||||
updateField('firstNameError', '');
|
||||
}
|
||||
}}
|
||||
placeholder={t('auth:register.first_name_placeholder', 'John')}
|
||||
error={errors.firstName}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium mb-1 text-[var(--text-primary)]">
|
||||
{t('auth:register.last_name', 'Last Name')}
|
||||
</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
onChange={(e) => updateField('lastName', e.target.value)}
|
||||
onBlur={() => {
|
||||
const validation = validateNameField(formData.lastName, 'lastName');
|
||||
if (!validation.isValid) {
|
||||
updateField('lastNameError', validation.error);
|
||||
} else {
|
||||
updateField('lastNameError', '');
|
||||
}
|
||||
}}
|
||||
placeholder={t('auth:register.last_name_placeholder', 'Doe')}
|
||||
error={errors.lastName || formData.lastNameError}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1 text-[var(--text-primary)]">
|
||||
{t('auth:register.email', 'Email')}
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => updateField('email', e.target.value)}
|
||||
onBlur={() => {
|
||||
const validation = validateEmailField(formData.email);
|
||||
if (!validation.isValid) {
|
||||
updateField('emailError', validation.error);
|
||||
} else {
|
||||
updateField('emailError', '');
|
||||
}
|
||||
}}
|
||||
placeholder={t('auth:register.email_placeholder', 'your@email.com')}
|
||||
error={errors.email || formData.emailError}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1 text-[var(--text-primary)]">
|
||||
{t('auth:register.password', 'Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
onBlur={() => {
|
||||
const validation = validatePasswordField(formData.password);
|
||||
if (!validation.isValid) {
|
||||
updateField('passwordError', validation.error);
|
||||
} else {
|
||||
updateField('passwordError', '');
|
||||
}
|
||||
}}
|
||||
placeholder={t('auth:register.password_placeholder', '••••••••')}
|
||||
error={errors.password || formData.passwordError}
|
||||
className="w-full"
|
||||
rightIcon={showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
onRightIconClick={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
</div>
|
||||
<PasswordCriteria password={formData.password} className="mt-2" />
|
||||
|
||||
{/* Password Strength Meter */}
|
||||
{formData.password && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-xs text-[var(--text-tertiary)] mb-1">
|
||||
<span>{t('auth:register.password_strength', 'Password Strength')}</span>
|
||||
<span>{calculatePasswordStrength(formData.password)}</span>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1 flex-1 rounded-full transition-all ${
|
||||
getStrengthColor(index, formData.password)
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1 text-[var(--text-primary)]">
|
||||
{t('auth:register.confirm_password', 'Confirm Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => updateField('confirmPassword', e.target.value)}
|
||||
onBlur={() => {
|
||||
const validation = validateConfirmPassword(formData.confirmPassword);
|
||||
if (!validation.isValid) {
|
||||
updateField('confirmPasswordError', validation.error);
|
||||
} else {
|
||||
updateField('confirmPasswordError', '');
|
||||
}
|
||||
}}
|
||||
placeholder={t('auth:register.confirm_password_placeholder', '••••••••')}
|
||||
error={errors.confirmPassword || formData.confirmPasswordError}
|
||||
className="w-full"
|
||||
rightIcon={showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
onRightIconClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions - Mobile optimized */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<Checkbox
|
||||
id="acceptTerms"
|
||||
checked={formData.acceptTerms}
|
||||
onCheckedChange={(checked) => updateField('acceptTerms', checked)}
|
||||
error={errors.acceptTerms}
|
||||
label={(
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{t('auth:register.accept_terms', 'I accept the')}{' '}
|
||||
<a href="/terms" target="_blank" rel="noopener noreferrer" className="text-[var(--color-primary)] hover:underline font-medium">
|
||||
{t('auth:register.terms_of_service', 'Terms of Service')}
|
||||
</a>{' '}
|
||||
{t('auth:register.and', 'and')}{' '}
|
||||
<a href="/privacy" target="_blank" rel="noopener noreferrer" className="text-[var(--color-primary)] hover:underline font-medium">
|
||||
{t('auth:register.privacy_policy', 'Privacy Policy')}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Marketing and Analytics Consent - Stack on mobile */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Checkbox
|
||||
id="marketingConsent"
|
||||
checked={formData.marketingConsent}
|
||||
onCheckedChange={(checked) => updateField('marketingConsent', checked)}
|
||||
label={(
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('auth:register.marketing_consent', 'Subscribe to marketing communications (optional)')}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
id="analyticsConsent"
|
||||
checked={formData.analyticsConsent}
|
||||
onCheckedChange={(checked) => updateField('analyticsConsent', checked)}
|
||||
label={(
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{t('auth:register.analytics_consent', 'Allow analytics cookies to improve experience')}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4 sm:pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isFullWidth
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
loadingText={t('auth:register.submitting', 'Submitting...')}
|
||||
>
|
||||
{t('auth:register.continue', 'Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="text-center pt-2">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('auth:register.already_have_account', 'Already have an account?')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoginClick}
|
||||
className="text-[var(--color-primary)] hover:underline font-medium"
|
||||
>
|
||||
{t('auth:register.login', 'Log in')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pilot program message removed as requested */}
|
||||
{false && isPilot && (
|
||||
<div className="mt-4 p-3 bg-[var(--color-info-50)] rounded-lg">
|
||||
<p className="text-sm text-[var(--color-info-800)]">
|
||||
🎉 {t('auth:register.pilot_program', 'You are eligible for our pilot program!')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfoStep;
|
||||
797
frontend/src/components/domain/auth/PaymentStep.tsx
Normal file
797
frontend/src/components/domain/auth/PaymentStep.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useStartRegistration, useCompleteRegistration } from '@/api/hooks/auth';
|
||||
import { ThreeDSAuthenticationRequired } from '@/api/types/auth';
|
||||
import { Button, Card, Input } from '../../ui';
|
||||
import { Loader2, CreditCard, Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { subscriptionService, apiClient } from '../../../api';
|
||||
import { useAuthStore } from '../../../stores/auth.store';
|
||||
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
||||
import { showToast } from '../../../utils/toast';
|
||||
|
||||
// Supported countries for billing address
|
||||
const SUPPORTED_COUNTRIES = [
|
||||
{ code: 'ES', name: 'España' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'DE', name: 'Deutschland' },
|
||||
{ code: 'IT', name: 'Italia' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'US', name: 'United States' },
|
||||
];
|
||||
|
||||
interface PaymentStepProps {
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
onPaymentComplete: (paymentData: any) => void;
|
||||
registrationState: any;
|
||||
updateRegistrationState: (updates: any) => void;
|
||||
}
|
||||
|
||||
export const PaymentStep: React.FC<PaymentStepProps> = ({
|
||||
onNext,
|
||||
onBack,
|
||||
onPaymentComplete,
|
||||
registrationState,
|
||||
updateRegistrationState
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
// Use React Query mutations for atomic registration
|
||||
const startRegistrationMutation = useStartRegistration();
|
||||
const completeRegistrationMutation = useCompleteRegistration();
|
||||
|
||||
// Helper function to save registration state for 3DS redirect recovery
|
||||
const saveRegistrationStateFor3DSRedirect = (setupIntentId: string) => {
|
||||
const stateToSave = {
|
||||
basicInfo: registrationState.basicInfo,
|
||||
subscription: registrationState.subscription,
|
||||
paymentSetup: {
|
||||
customerId: registrationState.paymentSetup?.customerId || '',
|
||||
paymentMethodId: pendingPaymentMethodId || registrationState.paymentSetup?.paymentMethodId,
|
||||
planId: registrationState.subscription.planId,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const savedStateKey = `registration_state_${setupIntentId}`;
|
||||
sessionStorage.setItem(savedStateKey, JSON.stringify(stateToSave));
|
||||
console.log('Saved registration state for 3DS redirect recovery:', savedStateKey);
|
||||
};
|
||||
|
||||
// Helper function to perform auto-login after registration
|
||||
const performAutoLogin = (result: any) => {
|
||||
if (result.access_token && result.refresh_token) {
|
||||
// Set tokens in API client
|
||||
apiClient.setAuthToken(result.access_token);
|
||||
apiClient.setRefreshToken(result.refresh_token);
|
||||
|
||||
// Extract subscription from JWT
|
||||
const jwtSubscription = getSubscriptionFromJWT(result.access_token);
|
||||
const jwtTenantAccess = getTenantAccessFromJWT(result.access_token);
|
||||
const primaryTenantId = getPrimaryTenantIdFromJWT(result.access_token);
|
||||
|
||||
// Update auth store directly using setState
|
||||
useAuthStore.setState({
|
||||
user: result.user ? {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
full_name: result.user.full_name,
|
||||
is_active: result.user.is_active,
|
||||
is_verified: result.user.is_verified || false,
|
||||
created_at: result.user.created_at || new Date().toISOString(),
|
||||
role: result.user.role || 'admin',
|
||||
} : null,
|
||||
token: result.access_token,
|
||||
refreshToken: result.refresh_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
jwtSubscription,
|
||||
jwtTenantAccess,
|
||||
primaryTenantId,
|
||||
pendingSubscriptionId: result.subscription_id || null,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [requires3DS, setRequires3DS] = useState(false);
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [setupIntentId, setSetupIntentId] = useState<string | null>(null);
|
||||
// Store payment method ID separately for 3DS flow
|
||||
const [pendingPaymentMethodId, setPendingPaymentMethodId] = useState<string | null>(null);
|
||||
// Track if 3DS has been auto-triggered to prevent multiple calls
|
||||
const [threeDSTriggered, setThreeDSTriggered] = useState(false);
|
||||
const [billingDetails, setBillingDetails] = useState({
|
||||
name: registrationState.basicInfo.firstName + ' ' + registrationState.basicInfo.lastName,
|
||||
email: registrationState.basicInfo.email,
|
||||
address: {
|
||||
line1: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: navigator.language?.split('-')[1]?.toUpperCase() || 'ES', // Auto-detect country from browser
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to get plan price - uses plan metadata from registration state
|
||||
const getPlanPrice = (planId: string, billingInterval: string, couponCode?: string) => {
|
||||
// Get pricing from registration state (this contains the actual selected plan's pricing)
|
||||
const planMetadata = registrationState.subscription.planMetadata;
|
||||
|
||||
if (planMetadata) {
|
||||
// Use actual pricing from the plan metadata
|
||||
const price = billingInterval === 'monthly'
|
||||
? planMetadata.monthly_price
|
||||
: planMetadata.yearly_price;
|
||||
|
||||
// Apply coupon logic - PILOT2025 shows €0
|
||||
if (couponCode === 'PILOT2025') {
|
||||
return '€0';
|
||||
}
|
||||
|
||||
// Format price properly
|
||||
return subscriptionService.formatPrice(price);
|
||||
}
|
||||
|
||||
// Fallback pricing if metadata not available (should match backend default pricing)
|
||||
const fallbackPricing = {
|
||||
starter: { monthly: 9.99, yearly: 99.99 },
|
||||
professional: { monthly: 29.99, yearly: 299.99 },
|
||||
enterprise: { monthly: 99.99, yearly: 999.99 }
|
||||
};
|
||||
|
||||
const price = fallbackPricing[planId as keyof typeof fallbackPricing]?.[billingInterval as 'monthly' | 'yearly'] || 0;
|
||||
|
||||
// Apply coupon logic - PILOT2025 shows €0
|
||||
if (couponCode === 'PILOT2025') {
|
||||
return '€0';
|
||||
}
|
||||
|
||||
return subscriptionService.formatPrice(price);
|
||||
};
|
||||
|
||||
// Helper function to get plan name - uses plan metadata from registration state
|
||||
const getPlanName = (planId: string) => {
|
||||
const planMetadata = registrationState.subscription.planMetadata;
|
||||
|
||||
if (planMetadata && planMetadata.name) {
|
||||
return planMetadata.name;
|
||||
}
|
||||
|
||||
// Fallback names
|
||||
switch (planId) {
|
||||
case 'starter':
|
||||
return t('auth:plan_starter', 'Starter');
|
||||
case 'professional':
|
||||
return t('auth:plan_professional', 'Professional');
|
||||
case 'enterprise':
|
||||
return t('auth:plan_enterprise', 'Enterprise');
|
||||
default:
|
||||
return planId;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we already have a successful payment setup
|
||||
if (registrationState.paymentSetup?.success) {
|
||||
setSuccess(true);
|
||||
}
|
||||
}, [registrationState.paymentSetup]);
|
||||
|
||||
// Auto-trigger 3DS authentication when required
|
||||
useEffect(() => {
|
||||
if (requires3DS && clientSecret && stripe && !threeDSTriggered && !loading) {
|
||||
setThreeDSTriggered(true);
|
||||
handle3DSAuthentication();
|
||||
}
|
||||
}, [requires3DS, clientSecret, stripe, threeDSTriggered, loading]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!stripe || !elements) {
|
||||
setError(t('auth:stripe_not_loaded'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Submit the payment element to validate all inputs
|
||||
const { error: submitError } = await elements.submit();
|
||||
|
||||
if (submitError) {
|
||||
setError(submitError.message || t('auth:payment_validation_failed'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create payment method using PaymentElement
|
||||
const { error: stripeError, paymentMethod } = await stripe.createPaymentMethod({
|
||||
elements,
|
||||
params: {
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
email: billingDetails.email,
|
||||
address: billingDetails.address,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
setError(stripeError.message || t('auth:payment_method_creation_failed'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentMethod) {
|
||||
setError(t('auth:no_payment_method_created'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call backend to start registration with SetupIntent-first approach
|
||||
const paymentSetupResult = await startRegistrationMutation.mutateAsync({
|
||||
email: registrationState.basicInfo.email,
|
||||
password: registrationState.basicInfo.password || '',
|
||||
full_name: `${registrationState.basicInfo.firstName} ${registrationState.basicInfo.lastName}`,
|
||||
subscription_plan: registrationState.subscription.planId,
|
||||
payment_method_id: paymentMethod.id,
|
||||
billing_cycle: registrationState.subscription.billingInterval,
|
||||
coupon_code: registrationState.subscription.couponCode,
|
||||
});
|
||||
|
||||
// Handle 3DS authentication requirement
|
||||
if (paymentSetupResult.requires_action) {
|
||||
const setupIntentIdValue = paymentSetupResult.setup_intent_id ?? '';
|
||||
|
||||
setRequires3DS(true);
|
||||
setClientSecret(paymentSetupResult.client_secret ?? null);
|
||||
setSetupIntentId(setupIntentIdValue || null);
|
||||
// Store payment method ID for use after 3DS completion
|
||||
setPendingPaymentMethodId(paymentMethod.id);
|
||||
|
||||
// Update paymentSetup state with customer_id for redirect recovery
|
||||
updateRegistrationState({
|
||||
paymentSetup: {
|
||||
success: false,
|
||||
customerId: paymentSetupResult.customer_id ?? paymentSetupResult.payment_customer_id ?? '',
|
||||
subscriptionId: '',
|
||||
paymentMethodId: paymentMethod.id,
|
||||
planId: registrationState.subscription.planId,
|
||||
},
|
||||
});
|
||||
|
||||
// Save state for potential 3DS redirect recovery (external bank redirect)
|
||||
if (setupIntentIdValue) {
|
||||
saveRegistrationStateFor3DSRedirect(setupIntentIdValue);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Payment setup successful
|
||||
updateRegistrationState({
|
||||
paymentSetup: {
|
||||
success: true,
|
||||
customerId: paymentSetupResult.customer_id ?? '',
|
||||
subscriptionId: paymentSetupResult.subscription_id ?? '',
|
||||
paymentMethodId: paymentMethod.id,
|
||||
planId: registrationState.subscription.planId,
|
||||
},
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
onPaymentComplete(paymentSetupResult);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof ThreeDSAuthenticationRequired) {
|
||||
setRequires3DS(true);
|
||||
setClientSecret(error.client_secret);
|
||||
setSetupIntentId(error.setup_intent_id);
|
||||
|
||||
// Save state for potential 3DS redirect recovery
|
||||
if (error.setup_intent_id) {
|
||||
saveRegistrationStateFor3DSRedirect(error.setup_intent_id);
|
||||
}
|
||||
} else {
|
||||
setError(error instanceof Error ? error.message : t('auth:payment.payment_setup_failed', 'Payment setup failed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handle3DSAuthentication = async () => {
|
||||
if (!stripe || !clientSecret) {
|
||||
setError(t('auth:stripe_not_loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Confirm the SetupIntent with 3DS authentication
|
||||
// Payment method is already attached, so we use confirmCardSetup with just the clientSecret
|
||||
// This will trigger the 3DS modal from Stripe automatically
|
||||
const result = await stripe.confirmCardSetup(clientSecret);
|
||||
|
||||
// Check for errors
|
||||
if (result.error) {
|
||||
setError(result.error.message || t('auth:3ds_authentication_failed'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// SetupIntent is returned when confirmation succeeds
|
||||
const setupIntent = result.setupIntent;
|
||||
|
||||
if (!setupIntent || setupIntent.status !== 'succeeded') {
|
||||
setError(t('auth:3ds_authentication_not_completed'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the payment method ID from the confirmed SetupIntent or use the pending one
|
||||
const confirmedPaymentMethodId = typeof setupIntent.payment_method === 'string'
|
||||
? setupIntent.payment_method
|
||||
: setupIntent.payment_method?.id || pendingPaymentMethodId;
|
||||
|
||||
// Complete registration with verified SetupIntent using React Query mutation
|
||||
const verificationResult = await completeRegistrationMutation.mutateAsync({
|
||||
setup_intent_id: setupIntentId || '',
|
||||
user_data: {
|
||||
email: registrationState.basicInfo.email,
|
||||
password: registrationState.basicInfo.password || '',
|
||||
full_name: `${registrationState.basicInfo.firstName} ${registrationState.basicInfo.lastName}`,
|
||||
subscription_plan: registrationState.subscription.planId,
|
||||
billing_cycle: registrationState.subscription.billingInterval,
|
||||
payment_method_id: confirmedPaymentMethodId || pendingPaymentMethodId,
|
||||
coupon_code: registrationState.subscription.couponCode,
|
||||
},
|
||||
});
|
||||
|
||||
if (verificationResult.success) {
|
||||
updateRegistrationState({
|
||||
paymentSetup: {
|
||||
success: true,
|
||||
customerId: verificationResult.payment_customer_id ?? '',
|
||||
subscriptionId: verificationResult.subscription_id ?? '',
|
||||
paymentMethodId: confirmedPaymentMethodId || '',
|
||||
planId: registrationState.subscription.planId,
|
||||
threedsCompleted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Perform auto-login if tokens are returned
|
||||
const autoLoginSuccess = performAutoLogin(verificationResult);
|
||||
|
||||
if (autoLoginSuccess) {
|
||||
// Show success message
|
||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||
|
||||
// Navigate directly to onboarding
|
||||
navigate('/app/onboarding');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setRequires3DS(false);
|
||||
|
||||
// Create a combined result for the parent component (fallback if no tokens)
|
||||
const paymentCompleteResult = {
|
||||
...verificationResult,
|
||||
requires_3ds: true,
|
||||
threeds_completed: true,
|
||||
};
|
||||
|
||||
onPaymentComplete(paymentCompleteResult);
|
||||
} else {
|
||||
setError(verificationResult.message || t('auth:payment_verification_failed'));
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('auth:3ds_verification_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setRequires3DS(false);
|
||||
setClientSecret(null);
|
||||
setSetupIntentId(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 mx-auto">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
{t('auth:payment_setup_complete')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{t('auth:payment_setup_complete_description')}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={loading}
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{t('auth:continue_to_account_creation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (requires3DS) {
|
||||
return (
|
||||
<Card className="max-w-md mx-auto">
|
||||
<div className="p-6">
|
||||
{/* Show error state with retry option */}
|
||||
{error ? (
|
||||
<>
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 mb-4 mx-auto">
|
||||
<AlertCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-primary mb-2">
|
||||
{t('auth:verification_failed', 'Verification Failed')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm mb-4">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setThreeDSTriggered(false);
|
||||
setError(null);
|
||||
handle3DSAuthentication();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="bg-primary hover:bg-primary/90 flex-1"
|
||||
>
|
||||
{t('auth:try_again', 'Try Again')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRetry}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
{t('auth:use_different_card', 'Use Different Card')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Show loading state - 3DS modal will appear automatically */
|
||||
<>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-4 mx-auto">
|
||||
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-primary mb-2">
|
||||
{t('auth:verifying_payment', 'Verifying Payment')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-sm">
|
||||
{t('auth:bank_verification_opening', 'Your bank\'s verification window will open automatically...')}
|
||||
</p>
|
||||
<p className="text-text-tertiary text-xs mt-4">
|
||||
{t('auth:complete_in_popup', 'Please complete the verification in the popup window')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-lg mx-auto">
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
<h3 className="text-lg sm:text-xl font-bold text-text-primary mb-2 flex items-center justify-center gap-2">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
{t('auth:payment.payment_information', 'Payment Information')}
|
||||
</h3>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">
|
||||
{t('auth:payment.secure_payment', 'Your payment information is protected with end-to-end encryption')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Summary - Mobile optimized */}
|
||||
<div className="mb-4 sm:mb-6 p-3 sm:p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-primary)]">
|
||||
<h4 className="font-semibold text-[var(--text-primary)] mb-2 sm:mb-3 text-sm sm:text-base">
|
||||
{t('auth:payment.payment_summary_title', 'Payment Summary')}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3 text-sm">
|
||||
{/* Selected Plan */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{t('auth:payment.selected_plan', 'Selected Plan')}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--color-primary)]">
|
||||
{getPlanName(registrationState.subscription.planId)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Billing Cycle */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{t('auth:payment.billing_cycle', 'Billing Cycle')}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{registrationState.subscription.billingInterval === 'monthly'
|
||||
? t('auth:payment.monthly', 'Monthly')
|
||||
: t('auth:payment.yearly', 'Yearly')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pilot discount information - shown when pilot is active */}
|
||||
{registrationState.subscription.useTrial && (
|
||||
<div className="pt-2 border-t border-[var(--border-primary)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{t('auth:payment.price', 'Price')}
|
||||
</span>
|
||||
<span className="font-bold text-[var(--color-primary)]">
|
||||
€0/{registrationState.subscription.billingInterval === 'monthly' ? 'mo' : 'yr'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Small text showing post-trial pricing */}
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
{t('auth:payment.pilot_future_payment',
|
||||
'After 3 months, payment will be €{price}/{interval}',
|
||||
{
|
||||
price: getPlanPrice(registrationState.subscription.planId, registrationState.subscription.billingInterval),
|
||||
interval: registrationState.subscription.billingInterval === 'monthly' ? t('auth:payment.per_month', 'mo') : t('auth:payment.per_year', 'yr')
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Informative text about pilot discount */}
|
||||
<p className="text-xs text-blue-500 mt-1 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
{t('auth:payment.pilot_discount_active', 'Pilot discount active: 3 months free')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regular price display when not in pilot mode */}
|
||||
{!registrationState.subscription.useTrial && (
|
||||
<>
|
||||
{/* Price */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{t('auth:payment.price', 'Price')}
|
||||
</span>
|
||||
<span className="font-bold text-[var(--text-primary)]">
|
||||
{getPlanPrice(registrationState.subscription.planId, registrationState.subscription.billingInterval, registrationState.subscription.couponCode)}/{
|
||||
registrationState.subscription.billingInterval === 'monthly' ? 'mo' : 'yr'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Other promotions if not pilot */}
|
||||
{registrationState.subscription.couponCode && (
|
||||
<div className="pt-2 border-t border-[var(--border-primary)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{t('auth:payment.promotion', 'Promotion')}
|
||||
</span>
|
||||
<span className="font-medium text-green-500 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
{t('auth:payment.promotion_applied', 'Promotion applied')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Billing Details - Mobile optimized */}
|
||||
<div className="space-y-3 sm:space-y-4 mb-4 sm:mb-6">
|
||||
<Input
|
||||
label={t('auth:payment.cardholder_name', 'Cardholder Name')}
|
||||
placeholder={t('auth:payment.full_name', 'Full Name')}
|
||||
value={billingDetails.name}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, name: e.target.value})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={t('auth:payment.email', 'Email')}
|
||||
placeholder="tu.email@ejemplo.com"
|
||||
value={billingDetails.email}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, email: e.target.value})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* Address - Stack on mobile */}
|
||||
<Input
|
||||
label={t('auth:payment.address_line1', 'Address')}
|
||||
placeholder={t('auth:payment.street_address', 'Street Address')}
|
||||
value={billingDetails.address.line1}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, line1: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t('auth:payment.city', 'City')}
|
||||
placeholder={t('auth:payment.city', 'City')}
|
||||
value={billingDetails.address.city}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, city: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('auth:payment.postal_code', 'Postal Code')}
|
||||
placeholder={t('auth:payment.postal_code', 'Postal Code')}
|
||||
value={billingDetails.address.postal_code}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, postal_code: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t('auth:payment.state', 'State/Province')}
|
||||
placeholder={t('auth:payment.state', 'State/Province')}
|
||||
value={billingDetails.address.state}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, state: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t('auth:payment.country', 'Country')}
|
||||
placeholder={t('auth:payment.country', 'Country')}
|
||||
value={billingDetails.address.country}
|
||||
onChange={(e) => setBillingDetails({...billingDetails, address: {...billingDetails.address, country: e.target.value}})}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Element */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
{t('auth:payment.payment_details', 'Payment Details')}
|
||||
</label>
|
||||
<div className="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800">
|
||||
<PaymentElement
|
||||
options={{
|
||||
layout: 'tabs',
|
||||
fields: {
|
||||
billingDetails: 'auto',
|
||||
},
|
||||
wallets: {
|
||||
applePay: 'auto',
|
||||
googlePay: 'auto',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary mt-2 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
{t('auth:payment.payment_info_secure', 'Your payment information is secure')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 px-3 py-2 sm:px-4 sm:py-3 rounded-lg text-sm flex items-start gap-2" role="alert">
|
||||
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={loading}
|
||||
loadingText={t('auth:payment.processing', 'Processing...')}
|
||||
disabled={!stripe || loading}
|
||||
className="w-full"
|
||||
>
|
||||
{t('auth:payment.complete_payment', 'Complete Payment')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Back Button */}
|
||||
<div className="mt-3 sm:mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t('auth:payment.back', 'Back')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Add to i18n translations
|
||||
// payment_information: "Payment Information"
|
||||
// payment_information_description: "Please enter your payment details to complete registration"
|
||||
// payment_setup_complete: "Payment Setup Complete"
|
||||
// payment_setup_complete_description: "Your payment method has been successfully set up"
|
||||
// continue_to_account_creation: "Continue to Account Creation"
|
||||
// 3ds_authentication_required: "3D Secure Authentication Required"
|
||||
// 3ds_authentication_description: "Your bank requires additional authentication for this payment"
|
||||
// complete_3ds_authentication: "Complete Authentication"
|
||||
// complete_payment: "Complete Payment"
|
||||
// stripe_not_loaded: "Payment system not loaded. Please refresh the page."
|
||||
// payment_method_creation_failed: "Failed to create payment method"
|
||||
// no_payment_method_created: "No payment method was created"
|
||||
// payment_setup_failed: "Payment setup failed. Please try again."
|
||||
// 3ds_authentication_failed: "3D Secure authentication failed"
|
||||
// 3ds_authentication_not_completed: "3D Secure authentication not completed"
|
||||
// payment_verification_failed: "Payment verification failed"
|
||||
// 3ds_verification_failed: "3D Secure verification failed"
|
||||
// processing: "Processing..."
|
||||
// back: "Back"
|
||||
// cancel: "Cancel"
|
||||
// cardholder_name: "Cardholder Name"
|
||||
// full_name: "Full Name"
|
||||
// email: "Email"
|
||||
// address_line1: "Address"
|
||||
// street_address: "Street Address"
|
||||
// city: "City"
|
||||
// state: "State/Province"
|
||||
// postal_code: "Postal Code"
|
||||
// country: "Country"
|
||||
// payment_details: "Payment Details"
|
||||
// secure_payment: "Your payment information is protected with end-to-end encryption"
|
||||
// payment_info_secure: "Your payment information is secure"
|
||||
// payment_summary_title: "Payment Summary"
|
||||
// selected_plan: "Selected Plan"
|
||||
// billing_cycle: "Billing Cycle"
|
||||
// price: "Price"
|
||||
// promotion: "Promotion"
|
||||
// three_months_free: "3 months free"
|
||||
// plan_starter: "Starter"
|
||||
// plan_professional: "Professional"
|
||||
// plan_enterprise: "Enterprise"
|
||||
// monthly: "Monthly"
|
||||
// yearly: "Yearly"
|
||||
File diff suppressed because it is too large
Load Diff
642
frontend/src/components/domain/auth/RegistrationContainer.tsx
Normal file
642
frontend/src/components/domain/auth/RegistrationContainer.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* Registration Container Component
|
||||
* Main container that manages registration state and flow with atomic payment architecture
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Card, Progress } from '../../ui';
|
||||
import { showToast } from '../../../utils/toast';
|
||||
import { useAuthActions, useAuthLoading, useAuthError, useAuthStore } from '../../../stores/auth.store';
|
||||
import { usePilotDetection } from '../../../hooks/usePilotDetection';
|
||||
import { subscriptionService, apiClient } from '../../../api';
|
||||
import { validateEmail } from '../../../utils/validation';
|
||||
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
|
||||
import { loadStripe, Stripe } from '@stripe/stripe-js';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { useRegistrationState } from './hooks/useRegistrationState';
|
||||
import BasicInfoStep from './BasicInfoStep';
|
||||
import SubscriptionStep from './SubscriptionStep';
|
||||
import { PaymentStep } from './PaymentStep';
|
||||
import { CheckCircle, Circle } from 'lucide-react';
|
||||
|
||||
// Helper to get Stripe key from runtime config or build-time env
|
||||
const getStripeKey = (): string => {
|
||||
if (typeof window !== 'undefined' && window.__RUNTIME_CONFIG__?.VITE_STRIPE_PUBLISHABLE_KEY) {
|
||||
return window.__RUNTIME_CONFIG__.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
}
|
||||
return import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_test_51234567890123456789012345678901234567890123456789012345678901234567890123456789012345';
|
||||
};
|
||||
|
||||
const stripePromise = loadStripe(getStripeKey());
|
||||
|
||||
interface RegistrationContainerProps {
|
||||
onSuccess?: () => void;
|
||||
onLoginClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
|
||||
onSuccess,
|
||||
onLoginClick,
|
||||
className
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isPilot, couponCode } = usePilotDetection();
|
||||
const [searchParams] = useSearchParams();
|
||||
const {
|
||||
createRegistrationPaymentSetup,
|
||||
verifySetupIntent,
|
||||
completeRegistrationAfterPayment
|
||||
} = useAuthActions();
|
||||
const isLoading = useAuthLoading();
|
||||
const error = useAuthError();
|
||||
|
||||
// Helper function to perform auto-login after registration
|
||||
const performAutoLogin = (result: any) => {
|
||||
if (result.access_token && result.refresh_token) {
|
||||
// Set tokens in API client
|
||||
apiClient.setAuthToken(result.access_token);
|
||||
apiClient.setRefreshToken(result.refresh_token);
|
||||
|
||||
// Extract subscription from JWT
|
||||
const jwtSubscription = getSubscriptionFromJWT(result.access_token);
|
||||
const jwtTenantAccess = getTenantAccessFromJWT(result.access_token);
|
||||
const primaryTenantId = getPrimaryTenantIdFromJWT(result.access_token);
|
||||
|
||||
// Update auth store directly using setState
|
||||
useAuthStore.setState({
|
||||
user: result.user ? {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
full_name: result.user.full_name,
|
||||
is_active: result.user.is_active,
|
||||
is_verified: result.user.is_verified || false,
|
||||
created_at: result.user.created_at || new Date().toISOString(),
|
||||
role: result.user.role || 'admin',
|
||||
} : null,
|
||||
token: result.access_token,
|
||||
refreshToken: result.refresh_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
jwtSubscription,
|
||||
jwtTenantAccess,
|
||||
primaryTenantId,
|
||||
pendingSubscriptionId: result.subscription_id || null,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get URL parameters
|
||||
const planParam = searchParams.get('plan');
|
||||
const VALID_PLANS = ['starter', 'professional', 'enterprise'];
|
||||
const preSelectedPlan = (planParam && VALID_PLANS.includes(planParam)) ? planParam : null;
|
||||
|
||||
const billingParam = searchParams.get('billing_cycle');
|
||||
const urlBillingCycle = (billingParam === 'monthly' || billingParam === 'yearly') ? billingParam : null;
|
||||
|
||||
const urlPilotParam = searchParams.get('pilot') === 'true';
|
||||
// Use URL parameter to override pilot detection if present
|
||||
const effectivePilotState = urlPilotParam || isPilot;
|
||||
|
||||
// 3DS redirect parameters (Stripe adds these after redirect)
|
||||
const setupIntentParam = searchParams.get('setup_intent');
|
||||
const setupIntentClientSecretParam = searchParams.get('setup_intent_client_secret');
|
||||
const redirectStatusParam = searchParams.get('redirect_status');
|
||||
|
||||
// State for 3DS redirect recovery
|
||||
const [redirectRecoveryInProgress, setRedirectRecoveryInProgress] = useState(false);
|
||||
const [redirectRecoveryError, setRedirectRecoveryError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
step,
|
||||
registrationState,
|
||||
updateRegistrationState,
|
||||
nextStep,
|
||||
prevStep,
|
||||
isSubmitting,
|
||||
setIsSubmitting
|
||||
} = useRegistrationState({
|
||||
initialPlan: preSelectedPlan || 'starter',
|
||||
initialBillingCycle: urlBillingCycle || 'monthly',
|
||||
isPilot: effectivePilotState // Use the effective pilot state
|
||||
});
|
||||
|
||||
const [stripeInstance, setStripeInstance] = useState<Stripe | null>(null);
|
||||
const [paymentComplete, setPaymentComplete] = useState(false);
|
||||
const [paymentData, setPaymentData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStripeInstance = async () => {
|
||||
const stripe = await stripePromise;
|
||||
setStripeInstance(stripe);
|
||||
};
|
||||
loadStripeInstance();
|
||||
}, []);
|
||||
|
||||
// Handle 3DS redirect recovery when user returns from bank page
|
||||
useEffect(() => {
|
||||
const handle3DSRedirectRecovery = async () => {
|
||||
// Only proceed if we have the redirect parameters
|
||||
if (!setupIntentParam || !redirectStatusParam || !stripeInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't re-process if already in progress or already completed
|
||||
if (redirectRecoveryInProgress || paymentComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRedirectRecoveryInProgress(true);
|
||||
setRedirectRecoveryError(null);
|
||||
|
||||
try {
|
||||
console.log('3DS redirect recovery: Processing redirect return', {
|
||||
setupIntent: setupIntentParam,
|
||||
redirectStatus: redirectStatusParam
|
||||
});
|
||||
|
||||
// Verify the redirect status
|
||||
if (redirectStatusParam !== 'succeeded') {
|
||||
const errorMessage = t('auth:payment.3ds_redirect_failed', '3DS verification was not successful. Please try again.');
|
||||
setRedirectRecoveryError(errorMessage);
|
||||
showToast.error(errorMessage);
|
||||
setRedirectRecoveryInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve saved registration state from sessionStorage
|
||||
const savedStateKey = `registration_state_${setupIntentParam}`;
|
||||
const savedStateJson = sessionStorage.getItem(savedStateKey);
|
||||
|
||||
if (!savedStateJson) {
|
||||
// No saved state - user needs to restart registration
|
||||
const errorMessage = t('auth:payment.session_expired', 'Your registration session has expired. Please start again.');
|
||||
setRedirectRecoveryError(errorMessage);
|
||||
showToast.error(errorMessage);
|
||||
setRedirectRecoveryInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const savedState = JSON.parse(savedStateJson);
|
||||
console.log('3DS redirect recovery: Found saved state', savedState);
|
||||
|
||||
// Restore registration state
|
||||
updateRegistrationState({
|
||||
basicInfo: savedState.basicInfo,
|
||||
subscription: savedState.subscription,
|
||||
paymentSetup: savedState.paymentSetup,
|
||||
step: 'payment'
|
||||
});
|
||||
|
||||
// Verify the SetupIntent status with our backend
|
||||
const verificationResult = await verifySetupIntent(setupIntentParam);
|
||||
|
||||
if (!verificationResult.verified) {
|
||||
const errorMessage = verificationResult.error || t('auth:payment.verification_failed', 'Payment verification failed.');
|
||||
setRedirectRecoveryError(errorMessage);
|
||||
showToast.error(errorMessage);
|
||||
setRedirectRecoveryInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('3DS redirect recovery: SetupIntent verified', verificationResult);
|
||||
|
||||
// SetupIntent is verified - now complete the registration
|
||||
const registrationData = {
|
||||
email: savedState.basicInfo.email,
|
||||
full_name: `${savedState.basicInfo.firstName} ${savedState.basicInfo.lastName}`,
|
||||
password: savedState.basicInfo.password,
|
||||
subscription_plan: savedState.subscription.planId,
|
||||
billing_cycle: savedState.subscription.billingInterval,
|
||||
payment_customer_id: savedState.paymentSetup.customerId,
|
||||
payment_method_id: savedState.paymentSetup.paymentMethodId || verificationResult.payment_method_id,
|
||||
subscription_id: verificationResult.subscription_id,
|
||||
coupon_code: effectivePilotState ? couponCode : savedState.subscription.couponCode,
|
||||
terms_accepted: savedState.basicInfo.acceptTerms,
|
||||
privacy_accepted: savedState.basicInfo.acceptTerms,
|
||||
marketing_consent: savedState.basicInfo.marketingConsent,
|
||||
analytics_consent: savedState.basicInfo.analyticsConsent,
|
||||
setup_intent_id: setupIntentParam,
|
||||
threeds_completed: true
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
// Clean up saved state
|
||||
sessionStorage.removeItem(savedStateKey);
|
||||
|
||||
// Clean up URL parameters
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('setup_intent');
|
||||
newUrl.searchParams.delete('setup_intent_client_secret');
|
||||
newUrl.searchParams.delete('redirect_status');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
|
||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||
|
||||
// Navigate to onboarding (same as in PaymentStep)
|
||||
// Use a small timeout to ensure state updates are processed before navigation
|
||||
setTimeout(() => {
|
||||
navigate('/app/onboarding');
|
||||
}, 100);
|
||||
} else {
|
||||
setRedirectRecoveryError(result.message || t('auth:registration_failed', 'Registration failed'));
|
||||
showToast.error(result.message || t('auth:registration_failed', 'Registration failed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('3DS redirect recovery error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : t('auth:payment.recovery_failed', 'Failed to recover payment session');
|
||||
setRedirectRecoveryError(errorMessage);
|
||||
showToast.error(errorMessage);
|
||||
} finally {
|
||||
setRedirectRecoveryInProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
handle3DSRedirectRecovery();
|
||||
}, [setupIntentParam, redirectStatusParam, stripeInstance, paymentComplete, redirectRecoveryInProgress]);
|
||||
|
||||
const handlePaymentComplete = (data: any) => {
|
||||
setPaymentData(data);
|
||||
setPaymentComplete(true);
|
||||
nextStep();
|
||||
};
|
||||
|
||||
const handleRegistrationSubmit = async () => {
|
||||
try {
|
||||
if (step === 'basic_info') {
|
||||
// Validate basic info and proceed to next step
|
||||
const isValid = validateBasicInfo();
|
||||
if (!isValid) return;
|
||||
|
||||
// Skip subscription step if plan was pre-selected from URL (landing page selection)
|
||||
if (preSelectedPlan) {
|
||||
// Ensure subscription data is set with the pre-selected plan
|
||||
updateRegistrationState({
|
||||
step: 'payment',
|
||||
subscription: {
|
||||
...registrationState.subscription,
|
||||
planId: preSelectedPlan,
|
||||
billingInterval: urlBillingCycle || registrationState.subscription.billingInterval
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Go to subscription selection step
|
||||
nextStep();
|
||||
}
|
||||
} else if (step === 'subscription') {
|
||||
// Validate subscription selection and proceed to payment step
|
||||
const isValid = validateSubscription();
|
||||
if (!isValid) return;
|
||||
nextStep();
|
||||
} else if (step === 'payment') {
|
||||
// Payment step is handled by PaymentStep component
|
||||
// This should not be reached as PaymentStep handles its own submission
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration step error:', error);
|
||||
showToast.error(t('auth:alerts.error_create'), {
|
||||
title: t('auth:alerts.error')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateBasicInfo = (): boolean => {
|
||||
const { basicInfo } = registrationState;
|
||||
const newErrors: any = {};
|
||||
|
||||
if (!basicInfo.email || !validateEmail(basicInfo.email)) {
|
||||
newErrors.email = t('auth:register.invalid_email');
|
||||
}
|
||||
|
||||
if (!basicInfo.firstName || basicInfo.firstName.length < 2) {
|
||||
newErrors.firstName = t('auth:register.invalid_first_name');
|
||||
}
|
||||
|
||||
if (!basicInfo.lastName || basicInfo.lastName.length < 2) {
|
||||
newErrors.lastName = t('auth:register.invalid_last_name');
|
||||
}
|
||||
|
||||
if (!basicInfo.password || basicInfo.password.length < 8) {
|
||||
newErrors.password = t('auth:register.invalid_password');
|
||||
}
|
||||
|
||||
if (!basicInfo.acceptTerms) {
|
||||
newErrors.acceptTerms = t('auth:register.terms_required');
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
updateRegistrationState({ errors: newErrors });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateSubscription = (): boolean => {
|
||||
const { subscription } = registrationState;
|
||||
|
||||
if (!subscription.planId || !subscription.billingInterval) {
|
||||
updateRegistrationState({
|
||||
errors: { subscription: t('auth:register.select_plan') }
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCompleteRegistration = async () => {
|
||||
if (!paymentData) {
|
||||
showToast.error(t('auth:alerts.payment_required'), {
|
||||
title: t('auth:alerts.error')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const registrationData = {
|
||||
email: registrationState.basicInfo.email,
|
||||
full_name: `${registrationState.basicInfo.firstName} ${registrationState.basicInfo.lastName}`,
|
||||
password: registrationState.basicInfo.password,
|
||||
subscription_plan: registrationState.subscription.planId,
|
||||
billing_cycle: registrationState.subscription.billingInterval,
|
||||
payment_customer_id: paymentData.customer_id,
|
||||
payment_method_id: paymentData.payment_method_id,
|
||||
subscription_id: paymentData.subscription_id,
|
||||
coupon_code: isPilot ? couponCode : registrationState.subscription.couponCode,
|
||||
terms_accepted: registrationState.basicInfo.acceptTerms,
|
||||
privacy_accepted: registrationState.basicInfo.acceptTerms,
|
||||
marketing_consent: registrationState.basicInfo.marketingConsent,
|
||||
analytics_consent: registrationState.basicInfo.analyticsConsent,
|
||||
threeds_completed: paymentData.threeds_completed || false,
|
||||
setup_intent_id: paymentData.setup_intent_id,
|
||||
};
|
||||
|
||||
// Complete registration after successful payment
|
||||
const result = await completeRegistrationAfterPayment(registrationData);
|
||||
|
||||
if (result.success) {
|
||||
showToast.success(t('auth:register.registration_complete'), {
|
||||
title: t('auth:alerts.success')
|
||||
});
|
||||
|
||||
// Perform auto-login if tokens are returned
|
||||
const autoLoginSuccess = performAutoLogin(result);
|
||||
|
||||
if (autoLoginSuccess) {
|
||||
// Show success message
|
||||
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
|
||||
|
||||
// Use a small timeout to ensure state updates are processed before navigation
|
||||
setTimeout(() => {
|
||||
navigate('/app/onboarding');
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Navigate to onboarding after a delay if auto-login didn't happen
|
||||
setTimeout(() => {
|
||||
navigate('/app/onboarding');
|
||||
}, 1000); // Small delay to allow the success toast to be seen
|
||||
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showToast.error(result.message || t('auth:alerts.registration_error'), {
|
||||
title: t('auth:alerts.error')
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration completion error:', error);
|
||||
showToast.error(t('auth:alerts.registration_error'), {
|
||||
title: t('auth:alerts.error')
|
||||
});
|
||||
|
||||
// Even if there's an error, try to navigate to onboarding
|
||||
// The user might still be able to log in manually
|
||||
setTimeout(() => {
|
||||
navigate('/app/onboarding');
|
||||
}, 1000);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate progress percentage
|
||||
const getProgressPercentage = () => {
|
||||
const steps = preSelectedPlan
|
||||
? ['basic_info', 'payment', 'complete']
|
||||
: ['basic_info', 'subscription', 'payment', 'complete'];
|
||||
const currentIndex = steps.indexOf(step);
|
||||
return currentIndex >= 0 ? ((currentIndex + 1) / steps.length) * 100 : 0;
|
||||
};
|
||||
|
||||
// Get step names for progress indicator
|
||||
const getStepName = (stepKey: RegistrationStep) => {
|
||||
switch (stepKey) {
|
||||
case 'basic_info': return t('auth:register.step_basic_info', 'Información Básica');
|
||||
case 'subscription': return t('auth:register.step_subscription', 'Suscripción');
|
||||
case 'payment': return t('auth:register.step_payment', 'Pago');
|
||||
case 'complete': return t('auth:register.step_complete', 'Completar');
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which steps to show in progress indicator
|
||||
const progressSteps: RegistrationStep[] = preSelectedPlan
|
||||
? ['basic_info', 'payment', 'complete']
|
||||
: ['basic_info', 'subscription', 'payment', 'complete'];
|
||||
|
||||
// Render current step
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case 'basic_info':
|
||||
return (
|
||||
<BasicInfoStep
|
||||
formData={registrationState.basicInfo}
|
||||
errors={registrationState.errors}
|
||||
updateField={(field, value) => updateRegistrationState({
|
||||
basicInfo: { ...registrationState.basicInfo, [field]: value }
|
||||
})}
|
||||
onNext={handleRegistrationSubmit}
|
||||
isPilot={isPilot}
|
||||
isSubmitting={isSubmitting}
|
||||
onLoginClick={onLoginClick}
|
||||
/>
|
||||
);
|
||||
case 'subscription':
|
||||
return (
|
||||
<SubscriptionStep
|
||||
selectedPlan={registrationState.subscription.planId}
|
||||
billingCycle={registrationState.subscription.billingInterval}
|
||||
useTrial={registrationState.subscription.useTrial}
|
||||
couponCode={registrationState.subscription.couponCode}
|
||||
updateField={(field, value) => updateRegistrationState({
|
||||
subscription: { ...registrationState.subscription, [field]: value }
|
||||
})}
|
||||
onNext={handleRegistrationSubmit}
|
||||
onBack={prevStep}
|
||||
isPilot={isPilot}
|
||||
/>
|
||||
);
|
||||
case 'payment':
|
||||
return (
|
||||
<Elements
|
||||
stripe={stripeInstance}
|
||||
options={{
|
||||
mode: 'setup',
|
||||
currency: 'eur',
|
||||
paymentMethodCreation: 'manual' // Required for Payment Element
|
||||
}}
|
||||
>
|
||||
<PaymentStep
|
||||
onNext={handleRegistrationSubmit}
|
||||
onBack={prevStep}
|
||||
onPaymentComplete={handlePaymentComplete}
|
||||
registrationState={registrationState}
|
||||
updateRegistrationState={updateRegistrationState}
|
||||
/>
|
||||
</Elements>
|
||||
);
|
||||
case 'complete':
|
||||
return (
|
||||
<div className="registration-complete-step">
|
||||
<h2>{t('auth:register.registration_complete')}</h2>
|
||||
<p>{t('auth:register.registration_complete_description')}</p>
|
||||
<button
|
||||
onClick={handleCompleteRegistration}
|
||||
disabled={isSubmitting}
|
||||
className="complete-registration-button"
|
||||
>
|
||||
{isSubmitting ? t('auth:register.completing') : t('auth:register.complete_registration')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state during 3DS redirect recovery
|
||||
if (redirectRecoveryInProgress) {
|
||||
return (
|
||||
<Card className={`registration-container w-full max-w-xl mx-auto px-4 sm:px-6 ${className || ''}`} variant="elevated" padding="md" shadow="lg">
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[var(--color-primary)] border-t-transparent mb-4"></div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{t('auth:payment.verifying_payment', 'Verifying your payment...')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] text-center">
|
||||
{t('auth:payment.completing_registration', 'Please wait while we complete your registration.')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state if redirect recovery failed
|
||||
if (redirectRecoveryError && setupIntentParam) {
|
||||
return (
|
||||
<Card className={`registration-container w-full max-w-xl mx-auto px-4 sm:px-6 ${className || ''}`} variant="elevated" padding="md" shadow="lg">
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
|
||||
<Circle className="w-6 h-6 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{t('auth:payment.recovery_failed_title', 'Payment Verification Failed')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] text-center mb-4">
|
||||
{redirectRecoveryError}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Clear error and redirect params, allow user to restart
|
||||
setRedirectRecoveryError(null);
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete('setup_intent');
|
||||
newUrl.searchParams.delete('setup_intent_client_secret');
|
||||
newUrl.searchParams.delete('redirect_status');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
}}
|
||||
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('auth:payment.try_again', 'Try Again')}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`registration-container w-full ${step === 'subscription' ? 'max-w-6xl' : 'max-w-xl'} mx-auto px-4 sm:px-6 ${className || ''}`} variant="elevated" padding="md" shadow="lg">
|
||||
{/* Progress Indicator - Simplified for mobile */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
{/* Progress Header */}
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-[var(--text-primary)]">
|
||||
{t('auth:register.registration_progress', 'Registration Progress')}
|
||||
</h3>
|
||||
<span className="text-lg sm:text-xl font-bold text-[var(--color-primary)]">
|
||||
{Math.round(getProgressPercentage())}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Step Indicators - Compact on mobile */}
|
||||
<div className="flex items-center justify-between mb-3 gap-1">
|
||||
{progressSteps.map((stepKey, index) => {
|
||||
const isActive = step === stepKey;
|
||||
const isCompleted = progressSteps.indexOf(step) > index;
|
||||
|
||||
return (
|
||||
<div key={stepKey} className="flex flex-col items-center flex-1">
|
||||
{/* Step Circle - Smaller on mobile */}
|
||||
<div className={`flex items-center justify-center w-8 h-8 sm:w-10 sm:h-10 rounded-full border-2 transition-all ${
|
||||
isCompleted ? 'bg-[var(--color-primary)] border-[var(--color-primary)]' :
|
||||
isActive ? 'bg-[var(--color-primary)] border-[var(--color-primary)]' :
|
||||
'bg-gray-100 dark:bg-gray-700 border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
|
||||
) : (
|
||||
<span className={`text-xs sm:text-sm ${
|
||||
isActive ? 'text-white' : 'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Label - Only show on larger screens */}
|
||||
<span className={`text-[10px] sm:text-xs mt-1 font-medium text-center hidden sm:block ${
|
||||
isCompleted || isActive ? 'text-[var(--color-primary)]' : 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{getStepName(stepKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 sm:h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-[var(--color-primary)] h-full transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${getProgressPercentage()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderStep()}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistrationContainer;
|
||||
178
frontend/src/components/domain/auth/SubscriptionStep.tsx
Normal file
178
frontend/src/components/domain/auth/SubscriptionStep.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Subscription Step Component
|
||||
* Second step of registration - plan selection and billing cycle
|
||||
* Updated to use professional SubscriptionPricingCards component
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Checkbox } from '../../ui';
|
||||
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
|
||||
import { subscriptionService } from '../../../api';
|
||||
|
||||
interface SubscriptionStepProps {
|
||||
selectedPlan: 'starter' | 'professional' | 'enterprise';
|
||||
billingCycle: 'monthly' | 'yearly';
|
||||
useTrial: boolean;
|
||||
couponCode?: string;
|
||||
updateField: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
isPilot: boolean;
|
||||
}
|
||||
|
||||
export const SubscriptionStep: React.FC<SubscriptionStepProps> = ({
|
||||
selectedPlan,
|
||||
billingCycle,
|
||||
useTrial,
|
||||
couponCode,
|
||||
updateField,
|
||||
onNext,
|
||||
onBack,
|
||||
isPilot
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loadingPlanMetadata, setLoadingPlanMetadata] = useState(false);
|
||||
const [lastFetchedPlan, setLastFetchedPlan] = useState<string | null>(null);
|
||||
|
||||
// Fetch plan metadata when selectedPlan changes, with proper guards to prevent infinite loops
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchPlanMetadata = async () => {
|
||||
// Only fetch if we have a selected plan and it's different from the last fetched plan
|
||||
if (selectedPlan && selectedPlan !== lastFetchedPlan && isMounted) {
|
||||
setLoadingPlanMetadata(true);
|
||||
try {
|
||||
const metadata = await subscriptionService.getPlanMetadata(selectedPlan);
|
||||
if (metadata && isMounted) {
|
||||
updateField('planMetadata', {
|
||||
monthly_price: metadata.monthly_price,
|
||||
yearly_price: metadata.yearly_price,
|
||||
name: metadata.name,
|
||||
description: metadata.description
|
||||
});
|
||||
// Update the last fetched plan to prevent duplicate calls
|
||||
setLastFetchedPlan(selectedPlan);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plan metadata:', error);
|
||||
// Update with fallback metadata if API call fails
|
||||
if (isMounted) {
|
||||
const fallbackMetadata = getFallbackPlanMetadata(selectedPlan);
|
||||
updateField('planMetadata', fallbackMetadata);
|
||||
setLastFetchedPlan(selectedPlan); // Still update to prevent retry
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoadingPlanMetadata(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlanMetadata();
|
||||
|
||||
// Automatically apply PILOT2025 coupon for pilot users with trial enabled
|
||||
if (isPilot && useTrial && !couponCode) {
|
||||
updateField('couponCode', 'PILOT2025');
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [selectedPlan, updateField, lastFetchedPlan, isPilot, useTrial, couponCode]);
|
||||
|
||||
const getFallbackPlanMetadata = (planId: string) => {
|
||||
// Fallback metadata that matches backend defaults
|
||||
const fallbackData = {
|
||||
starter: {
|
||||
monthly_price: 9.99,
|
||||
yearly_price: 99.99,
|
||||
name: t('auth:plan_starter', 'Starter'),
|
||||
description: t('auth:plan_starter_desc', 'Basic plan for small businesses')
|
||||
},
|
||||
professional: {
|
||||
monthly_price: 29.99,
|
||||
yearly_price: 299.99,
|
||||
name: t('auth:plan_professional', 'Professional'),
|
||||
description: t('auth:plan_professional_desc', 'Advanced plan for growing businesses')
|
||||
},
|
||||
enterprise: {
|
||||
monthly_price: 99.99,
|
||||
yearly_price: 999.99,
|
||||
name: t('auth:plan_enterprise', 'Enterprise'),
|
||||
description: t('auth:plan_enterprise_desc', 'Complete solution for large businesses')
|
||||
}
|
||||
};
|
||||
|
||||
return fallbackData[planId as keyof typeof fallbackData] || {
|
||||
monthly_price: 0,
|
||||
yearly_price: 0,
|
||||
name: planId,
|
||||
description: ''
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="subscription-step">
|
||||
<h2 className="text-2xl font-bold mb-6">
|
||||
{t('auth:register.choose_plan', 'Choose Your Plan')}
|
||||
</h2>
|
||||
|
||||
{/* Professional Pricing Cards Component */}
|
||||
<SubscriptionPricingCards
|
||||
mode="selection"
|
||||
selectedPlan={selectedPlan}
|
||||
onPlanSelect={(planKey) => updateField('planId', planKey)}
|
||||
showPilotBanner={false} // Pilot program disabled as requested
|
||||
pilotCouponCode={undefined}
|
||||
pilotTrialMonths={0}
|
||||
billingCycle={billingCycle}
|
||||
onBillingCycleChange={(cycle) => updateField('billingInterval', cycle)}
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
{/* Pilot trial option - automatically enabled for pilot users */}
|
||||
{isPilot && (
|
||||
<div className="mb-6 p-4 bg-[var(--color-info-50)] rounded-lg">
|
||||
<Checkbox
|
||||
id="useTrial"
|
||||
checked={useTrial}
|
||||
onChange={(e) => {
|
||||
updateField('useTrial', e.target.checked);
|
||||
// Automatically apply PILOT2025 coupon when trial is enabled
|
||||
if (e.target.checked) {
|
||||
updateField('couponCode', 'PILOT2025');
|
||||
} else {
|
||||
updateField('couponCode', undefined);
|
||||
}
|
||||
}}
|
||||
label={t('auth:register.enable_trial', 'Enable 3-month free trial')}
|
||||
className="mb-2 font-medium"
|
||||
/>
|
||||
<p className="text-sm text-[var(--color-info-800)] ml-6">
|
||||
{t('auth:register.trial_description', 'Get full access to Professional plan features for 3 months at no cost.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button type="button" variant="secondary" size="lg" onClick={onBack}>
|
||||
{t('auth:register.back', 'Back')}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="lg" onClick={handleSubmit}>
|
||||
{t('auth:register.continue', 'Continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionStep;
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Custom hook for managing registration form state
|
||||
* Provides centralized state management for the registration flow
|
||||
* Updated for new atomic architecture with 3DS support
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { validateEmail } from '../../../../utils/validation';
|
||||
|
||||
export type RegistrationStep = 'basic_info' | 'subscription' | 'payment' | 'complete';
|
||||
|
||||
export type BasicInfoData = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
marketingConsent: boolean;
|
||||
analyticsConsent: boolean;
|
||||
};
|
||||
|
||||
export type SubscriptionData = {
|
||||
planId: 'starter' | 'professional' | 'enterprise';
|
||||
billingInterval: 'monthly' | 'yearly';
|
||||
useTrial: boolean;
|
||||
couponCode?: string;
|
||||
planMetadata?: {
|
||||
monthly_price: number;
|
||||
yearly_price: number;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PaymentSetupData = {
|
||||
success: boolean;
|
||||
customerId?: string;
|
||||
subscriptionId?: string;
|
||||
paymentMethodId?: string;
|
||||
planId?: string;
|
||||
threedsCompleted?: boolean;
|
||||
};
|
||||
|
||||
export type RegistrationState = {
|
||||
basicInfo: BasicInfoData;
|
||||
subscription: SubscriptionData;
|
||||
paymentSetup?: PaymentSetupData;
|
||||
errors: any;
|
||||
};
|
||||
|
||||
interface RegistrationStateParams {
|
||||
initialPlan: 'starter' | 'professional' | 'enterprise';
|
||||
initialBillingCycle: 'monthly' | 'yearly';
|
||||
isPilot: boolean;
|
||||
}
|
||||
|
||||
export const useRegistrationState = ({
|
||||
initialPlan,
|
||||
initialBillingCycle,
|
||||
isPilot
|
||||
}: RegistrationStateParams) => {
|
||||
// Form state
|
||||
const [step, setStep] = useState<RegistrationStep>('basic_info');
|
||||
|
||||
const [registrationState, setRegistrationState] = useState<RegistrationState>({
|
||||
basicInfo: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false,
|
||||
marketingConsent: false,
|
||||
analyticsConsent: false
|
||||
},
|
||||
subscription: {
|
||||
planId: initialPlan,
|
||||
billingInterval: initialBillingCycle,
|
||||
useTrial: isPilot,
|
||||
couponCode: undefined,
|
||||
planMetadata: undefined
|
||||
},
|
||||
errors: {}
|
||||
});
|
||||
|
||||
// Loading state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Navigation functions
|
||||
const nextStep = useCallback(() => {
|
||||
setStep(prev => {
|
||||
switch (prev) {
|
||||
case 'basic_info': return 'subscription';
|
||||
case 'subscription': return 'payment';
|
||||
case 'payment': return 'complete';
|
||||
default: return prev;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStep(prev => {
|
||||
switch (prev) {
|
||||
case 'subscription': return 'basic_info';
|
||||
case 'payment': return 'subscription';
|
||||
case 'complete': return 'payment';
|
||||
default: return prev;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Update registration state function
|
||||
const updateRegistrationState = useCallback((updates: Partial<RegistrationState>) => {
|
||||
setRegistrationState(prev => ({
|
||||
...prev,
|
||||
...updates
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Validation function for basic info
|
||||
const validateBasicInfo = useCallback((): boolean => {
|
||||
const { basicInfo } = registrationState;
|
||||
const newErrors: any = {};
|
||||
let isValid = true;
|
||||
|
||||
if (!basicInfo.firstName.trim()) {
|
||||
newErrors.firstName = 'First name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!basicInfo.lastName.trim()) {
|
||||
newErrors.lastName = 'Last name is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
const emailValidation = validateEmail(basicInfo.email);
|
||||
if (!emailValidation.isValid) {
|
||||
newErrors.email = emailValidation.error || 'Invalid email';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!basicInfo.password) {
|
||||
newErrors.password = 'Password is required';
|
||||
isValid = false;
|
||||
} else if (basicInfo.password.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (basicInfo.password !== basicInfo.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!basicInfo.acceptTerms) {
|
||||
newErrors.acceptTerms = 'You must accept the terms';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
updateRegistrationState({ errors: newErrors });
|
||||
return isValid;
|
||||
}, [registrationState]);
|
||||
|
||||
// Validation function for subscription
|
||||
const validateSubscription = useCallback((): boolean => {
|
||||
const { subscription } = registrationState;
|
||||
const newErrors: any = {};
|
||||
let isValid = true;
|
||||
|
||||
if (!subscription.planId) {
|
||||
newErrors.planId = 'Please select a plan';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!subscription.billingInterval) {
|
||||
newErrors.billingInterval = 'Please select a billing cycle';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
updateRegistrationState({ errors: newErrors });
|
||||
return isValid;
|
||||
}, [registrationState]);
|
||||
|
||||
// Reset form
|
||||
const resetForm = useCallback(() => {
|
||||
setRegistrationState({
|
||||
basicInfo: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false,
|
||||
marketingConsent: false,
|
||||
analyticsConsent: false
|
||||
},
|
||||
subscription: {
|
||||
planId: initialPlan,
|
||||
billingInterval: initialBillingCycle,
|
||||
useTrial: isPilot,
|
||||
couponCode: undefined,
|
||||
planMetadata: undefined
|
||||
},
|
||||
errors: {}
|
||||
});
|
||||
setStep('basic_info');
|
||||
}, [initialPlan, initialBillingCycle, isPilot]);
|
||||
|
||||
return {
|
||||
step,
|
||||
registrationState,
|
||||
updateRegistrationState,
|
||||
nextStep,
|
||||
prevStep,
|
||||
validateBasicInfo,
|
||||
validateSubscription,
|
||||
resetForm,
|
||||
isSubmitting,
|
||||
setIsSubmitting
|
||||
};
|
||||
};
|
||||
|
||||
export default useRegistrationState;
|
||||
@@ -1,13 +1,13 @@
|
||||
// Authentication domain components
|
||||
export { default as LoginForm } from './LoginForm';
|
||||
export { default as RegisterForm } from './RegisterForm';
|
||||
export { default as RegistrationContainer } from './RegistrationContainer';
|
||||
export { default as PasswordResetForm } from './PasswordResetForm';
|
||||
export { default as ProfileSettings } from './ProfileSettings';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
LoginFormProps,
|
||||
RegisterFormProps,
|
||||
RegistrationContainerProps,
|
||||
PasswordResetFormProps,
|
||||
ProfileSettingsProps
|
||||
} from './types';
|
||||
@@ -28,18 +28,18 @@ export const authComponents = {
|
||||
'Mobile responsive design'
|
||||
]
|
||||
},
|
||||
RegisterForm: {
|
||||
name: 'RegisterForm',
|
||||
description: 'Multi-step registration form for bakery owners with progress indicator and comprehensive validation',
|
||||
RegistrationContainer: {
|
||||
name: 'RegistrationContainer',
|
||||
description: 'Atomic registration container with SetupIntent-first architecture and 3DS/3DS2 support',
|
||||
features: [
|
||||
'Multi-step registration (personal, bakery, security, verification)',
|
||||
'Progress indicator with step completion',
|
||||
'Spanish phone and postal code validation',
|
||||
'Comprehensive bakery-specific fields',
|
||||
'Terms and privacy acceptance',
|
||||
'Email verification workflow',
|
||||
'Real-time validation feedback',
|
||||
'Mobile responsive design'
|
||||
'Multi-step registration (basic info, subscription, payment, completion)',
|
||||
'SetupIntent-first approach for secure payment verification',
|
||||
'3DS/3DS2 authentication support',
|
||||
'Stripe Elements integration',
|
||||
'Comprehensive error handling',
|
||||
'Progressive form validation',
|
||||
'Mobile responsive design',
|
||||
'Internationalization support'
|
||||
]
|
||||
},
|
||||
PasswordResetForm: {
|
||||
|
||||
@@ -8,11 +8,10 @@ export interface LoginFormProps {
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterFormProps {
|
||||
export interface RegistrationContainerProps {
|
||||
onSuccess?: () => void;
|
||||
onLoginClick?: () => void;
|
||||
className?: string;
|
||||
showProgressSteps?: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordResetFormProps {
|
||||
|
||||
Reference in New Issue
Block a user