Add subcription feature 3

This commit is contained in:
Urtzi Alfaro
2026-01-15 20:45:49 +01:00
parent a4c3b7da3f
commit b674708a4c
83 changed files with 9451 additions and 6828 deletions

View 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;

View 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

View 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;

View 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;

View File

@@ -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;

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -121,6 +121,15 @@ export const PaymentMethodUpdateModal: React.FC<PaymentMethodUpdateModalProps> =
setSuccess(false);
try {
// Submit the card element to validate all inputs
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message || 'Failed to validate payment information');
setLoading(false);
return;
}
// Create payment method using Stripe Elements
const { paymentMethod, error: stripeError } = await stripe.createPaymentMethod({
type: 'card',

View File

@@ -153,8 +153,8 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
</Card>
)}
{/* Billing Cycle Toggle - Only show if not externally controlled */}
{!externalBillingCycle && (
{/* Billing Cycle Toggle - Always show in selection mode or when not externally controlled */}
{(mode === 'selection' || !externalBillingCycle) && (
<div className="flex justify-center mb-8">
<div className="inline-flex rounded-lg border-2 border-[var(--border-primary)] p-1 bg-[var(--bg-secondary)]">
<button
@@ -197,7 +197,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
)}
{/* Simplified Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-start lg:items-stretch">
<div className="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 items-start lg:items-stretch">
{Object.entries(plans).map(([tier, plan]) => {
const price = getPrice(plan);
const savings = getSavings(plan);
@@ -220,7 +220,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
key={tier}
{...cardProps}
className={`
relative rounded-2xl p-8 transition-all duration-300 block no-underline
relative rounded-2xl p-6 sm:p-8 transition-all duration-300 block no-underline
${(mode === 'settings' && !isCurrentPlan) || mode === 'selection' || mode === 'landing' ? 'cursor-pointer' : ''}
${isCurrentPlan
? 'bg-[var(--bg-secondary)] border-2 border-[var(--color-primary)] opacity-70 cursor-not-allowed'
@@ -264,10 +264,10 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
{/* Plan Header */}
<div className="mb-6">
<h3 className={`text-2xl font-bold mb-2 ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white' : 'text-[var(--text-primary)]'}`}>
<h3 className={`text-xl sm:text-2xl font-bold mb-2 ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{t(`plans.${tier}.name`, plan.name)}
</h3>
<p className={`text-sm ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
<p className={`text-xs sm:text-sm ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white/90' : 'text-[var(--text-secondary)]'}`}>
{plan.tagline_key ? t(plan.tagline_key) : plan.tagline || ''}
</p>
</div>
@@ -275,10 +275,10 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
{/* Price */}
<div className="mb-6">
<div className="flex items-baseline">
<span className={`text-4xl font-bold ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white' : 'text-[var(--text-primary)]'}`}>
<span className={`text-2xl sm:text-4xl font-bold ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white' : 'text-[var(--text-primary)]'}`}>
{subscriptionService.formatPrice(price)}
</span>
<span className={`ml-2 text-lg ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
<span className={`ml-1 sm:ml-2 text-sm sm:text-lg ${(isPopular || isSelected) && !isCurrentPlan ? 'text-white/80' : 'text-[var(--text-secondary)]'}`}>
/{billingCycle === 'monthly' ? t('ui.per_month') : t('ui.per_year')}
</span>
</div>
@@ -379,7 +379,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
{/* CTA Button */}
<Button
disabled={isCurrentPlan}
className={`w-full py-4 text-base font-semibold transition-all ${
className={`w-full py-3 sm:py-4 text-sm sm:text-base font-semibold transition-all ${
isCurrentPlan
? 'bg-gray-400 text-gray-700 cursor-not-allowed opacity-60'
: isSelected

View File

@@ -0,0 +1,199 @@
import React, { forwardRef } from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { clsx } from 'clsx';
export interface CheckboxProps extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
label?: React.ReactNode;
description?: string;
error?: string;
size?: 'sm' | 'md' | 'lg';
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
}
const Checkbox = forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxProps>(({
label,
description,
error,
size = 'md',
variant = 'primary',
className,
id,
...props
}, ref) => {
const checkboxId = id || `checkbox-${Math.random().toString(36).substr(2, 9)}`;
const hasError = !!error;
const sizeClasses = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5'
};
const variantClasses = {
primary: [
'data-[state=checked]:bg-[var(--color-primary)]',
'data-[state=checked]:text-white',
'data-[state=checked]:border-[var(--color-primary)]',
'data-[state=checked]:ring-2',
'data-[state=checked]:ring-[var(--color-primary)]/30',
// Light mode unchecked - use stronger border for better visibility
'border-[var(--border-secondary)]', // Using secondary border for more contrast
'bg-white',
'shadow-sm',
'hover:border-[var(--border-tertiary)]',
'hover:shadow-md',
'focus:ring-[var(--color-primary)]/20',
'transition-all duration-200',
// Dark mode - use stronger border for better visibility
'dark:border-[var(--border-secondary)]', // Using secondary border for more contrast in dark mode too
'dark:bg-[var(--surface-secondary)]', // Slightly different background for better contrast
'dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]',
'dark:hover:border-[var(--border-tertiary)]',
'dark:hover:bg-[var(--surface-tertiary)]', // Better contrast on hover
'dark:data-[state=checked]:bg-[var(--color-primary)]',
'dark:data-[state=checked]:border-[var(--color-primary)]',
'dark:data-[state=checked]:text-white'
],
secondary: [
'data-[state=checked]:bg-[var(--color-secondary)]',
'data-[state=checked]:text-white',
'data-[state=checked]:border-[var(--color-secondary)]',
'data-[state=checked]:ring-2',
'data-[state=checked]:ring-[var(--color-secondary)]/30',
// Light mode unchecked - use stronger border for better visibility
'border-[var(--border-secondary)]', // Using secondary border for more contrast
'bg-white',
'shadow-sm',
'hover:border-[var(--border-tertiary)]',
'hover:shadow-md',
'focus:ring-[var(--color-secondary)]/20',
'transition-all duration-200',
// Dark mode - use stronger border for better visibility
'dark:border-[var(--border-secondary)]', // Using secondary border for more contrast in dark mode too
'dark:bg-[var(--surface-secondary)]', // Slightly different background for better contrast
'dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]',
'dark:hover:border-[var(--border-tertiary)]',
'dark:hover:bg-[var(--surface-tertiary)]', // Better contrast on hover
'dark:data-[state=checked]:bg-[var(--color-secondary)]',
'dark:data-[state=checked]:border-[var(--color-secondary)]'
],
outline: [
'data-[state=checked]:bg-transparent',
'data-[state=checked]:border-[var(--color-primary)]',
'data-[state=checked]:text-[var(--color-primary)]',
'data-[state=checked]:ring-2',
'data-[state=checked]:ring-[var(--color-primary)]/30',
// Light mode unchecked - use stronger border for better visibility
'border-[var(--border-secondary)]', // Using secondary border for more contrast
'bg-white',
'shadow-sm',
'hover:border-[var(--border-tertiary)]',
'hover:shadow-md',
'focus:ring-[var(--color-primary)]/20',
'transition-all duration-200',
// Dark mode - use stronger border for better visibility
'dark:border-[var(--border-secondary)]', // Using secondary border for more contrast in dark mode too
'dark:bg-[var(--surface-secondary)]', // Slightly different background for better contrast
'dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.3)]',
'dark:hover:border-[var(--border-tertiary)]',
'dark:hover:bg-[var(--surface-tertiary)]', // Better contrast on hover
'dark:data-[state=checked]:border-[var(--color-primary)]',
'dark:data-[state=checked]:text-[var(--color-primary)]'
],
ghost: [
'data-[state=checked]:bg-transparent',
'data-[state=checked]:text-[var(--color-primary)]',
'data-[state=checked]:ring-2',
'data-[state=checked]:ring-[var(--color-primary)]/30',
// Ghost variant - use stronger border for better visibility
'border-[var(--border-secondary)]', // Using secondary border for more contrast
'bg-transparent',
'hover:border-[var(--border-tertiary)]',
'hover:bg-[var(--surface-secondary)]',
'hover:shadow-md',
'focus:ring-[var(--color-primary)]/20',
'transition-all duration-200',
// Dark mode
'dark:border-[var(--border-secondary)]', // Using secondary border for more contrast in dark mode too
'dark:hover:border-[var(--border-tertiary)]',
'dark:hover:bg-[var(--surface-secondary)]',
'dark:data-[state=checked]:text-[var(--color-primary)]'
]
};
const checkboxClasses = clsx(
'flex items-center justify-center',
'rounded border-2 transition-all duration-200', // border-2 for better visibility
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
'hover:shadow-md',
'shrink-0', // Prevent shrinking on mobile
'border-[var(--border-primary)]', // Ensure visible border by default
'dark:border-[var(--border-primary)]', // Ensure visible border in dark mode too
'shadow-sm', // Add subtle shadow for better visibility
'dark:shadow-[0_1px_2px_rgba(0,0,0,0.2)]', // Dark mode shadow for better visibility
sizeClasses[size],
variantClasses[variant],
{
'border-[var(--color-error)]': hasError,
},
className
);
const labelClasses = clsx(
'text-sm font-medium leading-none',
{
'text-[var(--text-primary)]': !hasError,
'text-[var(--color-error)]': hasError,
'peer-disabled:cursor-not-allowed peer-disabled:opacity-70': true,
'dark:text-[var(--text-primary)]': !hasError, // Ensure proper text color in dark mode
'dark:hover:text-[var(--text-primary)]': !hasError // Ensure proper hover text color in dark mode
}
);
const descriptionClasses = clsx(
'text-sm text-[var(--text-secondary)]',
{
'text-[var(--color-error)]': hasError
}
);
return (
<div className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<CheckboxPrimitive.Root
ref={ref}
id={checkboxId}
className={checkboxClasses}
aria-invalid={hasError}
aria-describedby={description ? `${checkboxId}-description` : undefined}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className={sizeClasses[size]} strokeWidth={3} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
{label && (
<label htmlFor={checkboxId} className={labelClasses}>
{label}
</label>
)}
</div>
{description && (
<p id={`${checkboxId}-description`} className={descriptionClasses}>
{description}
</p>
)}
{error && (
<p className="text-sm text-[var(--color-error)]">
{error}
</p>
)}
</div>
);
});
Checkbox.displayName = 'Checkbox';
export default Checkbox;

View File

@@ -0,0 +1,2 @@
export { default } from './Checkbox';
export type { CheckboxProps } from './Checkbox';

View File

@@ -13,6 +13,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
rightIcon?: React.ReactNode;
leftAddon?: React.ReactNode;
rightAddon?: React.ReactNode;
onLeftIconClick?: () => void;
onRightIconClick?: () => void;
}
const Input = forwardRef<HTMLInputElement, InputProps>(({
@@ -29,6 +31,9 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
rightAddon,
className,
id,
onRightIconClick,
onLeftIconClick,
disabled,
...props
}, ref) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
@@ -121,9 +126,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
const inputElement = (
<div className="relative">
{leftIcon && (
<div className={leftIconClasses}>
<button
type="button"
onClick={onLeftIconClick}
className={clsx(leftIconClasses, 'cursor-pointer hover:text-[var(--text-primary)]')}
disabled={disabled}
>
{leftIcon}
</div>
</button>
)}
<input
ref={ref}
@@ -138,9 +148,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
{...props}
/>
{rightIcon && (
<div className={rightIconClasses}>
<button
type="button"
onClick={onRightIconClick}
className={clsx(rightIconClasses, 'cursor-pointer hover:text-[var(--text-primary)]')}
disabled={disabled}
>
{rightIcon}
</div>
</button>
)}
</div>
);

View File

@@ -49,14 +49,21 @@ export const PasswordCriteria: React.FC<PasswordCriteriaProps> = ({
}
];
const validatedCriteria = criteria.map(criterion => ({
...criterion,
isValid: criterion.regex
? criterion.regex.test(password)
: criterion.checkFn
? criterion.checkFn(password)
: false
}));
const validatedCriteria = criteria.map(criterion => {
// If password is empty, all criteria should be considered invalid initially
if (!password) {
return { ...criterion, isValid: false };
}
return {
...criterion,
isValid: criterion.regex
? criterion.regex.test(password)
: criterion.checkFn
? criterion.checkFn(password)
: false
};
});
const displayCriteria = showOnlyFailed
? validatedCriteria.filter(c => !c.isValid)

View File

@@ -4,6 +4,7 @@ export { default as Input } from './Input';
export { default as Textarea } from './Textarea/Textarea';
export { default as FileUpload } from './FileUpload';
export { default as Card, CardHeader, CardBody, CardFooter, CardContent, CardTitle, CardDescription } from './Card';
export { default as Checkbox } from './Checkbox/Checkbox';
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
export { default as Table } from './Table';
export { Badge, CountBadge, StatusDot, SeverityBadge } from './Badge';
@@ -50,6 +51,7 @@ export type { ButtonProps } from './Button';
export type { InputProps } from './Input';
export type { TextareaProps } from './Textarea';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps, CardContentProps, CardTitleProps } from './Card';
export type { CheckboxProps } from './Checkbox/Checkbox';
export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
export type { TableProps, TableColumn } from './Table';
export type { BadgeProps, CountBadgeProps, StatusDotProps, SeverityBadgeProps, SeverityLevel } from './Badge';