802 lines
31 KiB
TypeScript
802 lines
31 KiB
TypeScript
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 and trial_period_days for redirect recovery
|
|
updateRegistrationState({
|
|
paymentSetup: {
|
|
success: false,
|
|
customerId: paymentSetupResult.customer_id ?? paymentSetupResult.payment_customer_id ?? '',
|
|
subscriptionId: '',
|
|
paymentMethodId: paymentMethod.id,
|
|
planId: registrationState.subscription.planId,
|
|
trialPeriodDays: paymentSetupResult.trial_period_days ?? (registrationState.subscription.useTrial ? 90 : 0),
|
|
},
|
|
});
|
|
|
|
// 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
|
|
// Send coupon_code to backend for trial period calculation
|
|
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,
|
|
// Pass customer_id for reference
|
|
customer_id: registrationState.paymentSetup?.customerId || '',
|
|
// Remove trial_period_days - backend will calculate from coupon_code
|
|
},
|
|
});
|
|
|
|
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"
|