Add subcription feature 5

This commit is contained in:
Urtzi Alfaro
2026-01-16 09:55:54 +01:00
parent 483a9f64cd
commit 6b43116efd
51 changed files with 1428 additions and 312 deletions

View File

@@ -279,6 +279,7 @@ export const BasicInfoStep: React.FC<BasicInfoStepProps> = ({
}}
placeholder={t('auth:register.confirm_password_placeholder', '••••••••')}
error={errors.confirmPassword || formData.confirmPasswordError}
isValid={formData.confirmPassword.length > 0 && formData.confirmPassword === formData.password}
className="w-full"
rightIcon={showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
onRightIconClick={() => setShowConfirmPassword(!showConfirmPassword)}

View File

@@ -3,7 +3,7 @@ import { Button, Input, Card } from '../../ui';
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
import { useAuthActions } from '../../../stores/auth.store';
import { showToast } from '../../../utils/toast';
import { useResetPassword } from '../../../api/hooks/auth';
import { useResetPasswordWithToken } from '../../../api/hooks/auth';
interface PasswordResetFormProps {
token?: string;
@@ -36,7 +36,7 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
const passwordInputRef = useRef<HTMLInputElement>(null);
// Password reset mutation hooks
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPassword();
const { mutateAsync: resetPasswordMutation, isPending: isResetting } = useResetPasswordWithToken();
const isLoading = isResetting;
const error = null;
@@ -184,7 +184,7 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
// Call the reset password API
await resetPasswordMutation({
token: token,
new_password: password
newPassword: password
});
showToast.success('¡Tu contraseña ha sido restablecida exitosamente! Ya puedes iniciar sesión.', {

View File

@@ -5,7 +5,7 @@ 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 { Loader2, CreditCard, Lock, AlertCircle } from 'lucide-react';
import { subscriptionService, apiClient } from '../../../api';
import { useAuthStore } from '../../../stores/auth.store';
import { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } from '../../../utils/jwt';
@@ -23,17 +23,13 @@ const SUPPORTED_COUNTRIES = [
];
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
}) => {
@@ -285,20 +281,44 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
return;
}
// Payment setup successful
updateRegistrationState({
paymentSetup: {
success: true,
customerId: paymentSetupResult.customer_id ?? '',
subscriptionId: paymentSetupResult.subscription_id ?? '',
paymentMethodId: paymentMethod.id,
planId: registrationState.subscription.planId,
// Payment setup successful (no 3DS required)
// Now call /complete-registration to create user, subscription, and get tokens
const completionResult = await completeRegistrationMutation.mutateAsync({
setup_intent_id: paymentSetupResult.setup_intent_id || '',
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: paymentMethod.id,
coupon_code: registrationState.subscription.couponCode,
customer_id: paymentSetupResult.customer_id ?? paymentSetupResult.payment_customer_id ?? '',
},
});
setSuccess(true);
onPaymentComplete(paymentSetupResult);
if (completionResult.success) {
updateRegistrationState({
paymentSetup: {
success: true,
customerId: completionResult.payment_customer_id ?? paymentSetupResult.customer_id ?? '',
subscriptionId: completionResult.subscription_id ?? '',
paymentMethodId: paymentMethod.id,
planId: registrationState.subscription.planId,
},
});
// Perform auto-login if tokens are returned
performAutoLogin(completionResult);
// Show success message and navigate to onboarding
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
navigate('/app/onboarding');
return;
} else {
setError(completionResult.message || t('auth:registration_failed', 'Registration failed'));
}
} catch (error) {
if (error instanceof ThreeDSAuthenticationRequired) {
setRequires3DS(true);
@@ -384,28 +404,12 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
});
// Perform auto-login if tokens are returned
const autoLoginSuccess = performAutoLogin(verificationResult);
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);
// Show success message and navigate to onboarding
showToast.success(t('auth:registration_complete', 'Registration complete! Redirecting to onboarding...'));
navigate('/app/onboarding');
return;
} else {
setError(verificationResult.message || t('auth:payment_verification_failed'));
}
@@ -424,30 +428,15 @@ export const PaymentStep: React.FC<PaymentStepProps> = ({
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>
);
}
// Auto-redirect on success (safety net - should not normally reach here)
useEffect(() => {
if (success) {
const timer = setTimeout(() => {
navigate('/app/onboarding');
}, 100);
return () => clearTimeout(timer);
}
}, [success, navigate]);
if (requires3DS) {
return (

View File

@@ -6,16 +6,16 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Card, Progress } from '../../ui';
import { Card } from '../../ui';
import { showToast } from '../../../utils/toast';
import { useAuthActions, useAuthLoading, useAuthError, useAuthStore } from '../../../stores/auth.store';
import { useAuthStore } from '../../../stores/auth.store';
import { usePilotDetection } from '../../../hooks/usePilotDetection';
import { subscriptionService, apiClient } from '../../../api';
import { 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 { useRegistrationState, RegistrationStep } from './hooks/useRegistrationState';
import BasicInfoStep from './BasicInfoStep';
import SubscriptionStep from './SubscriptionStep';
import { PaymentStep } from './PaymentStep';
@@ -38,7 +38,6 @@ interface RegistrationContainerProps {
}
export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
onSuccess,
onLoginClick,
className
}) => {
@@ -46,15 +45,12 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
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
// Get auth store functions for 3DS redirect recovery
const verifySetupIntent = useAuthStore((state) => state.verifySetupIntent);
const completeRegistrationAfterPayment = useAuthStore((state) => state.completeRegistrationAfterPayment);
// Helper function to perform auto-login after registration (used in 3DS redirect recovery)
const performAutoLogin = (result: any) => {
if (result.access_token && result.refresh_token) {
// Set tokens in API client
@@ -92,11 +88,12 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
}
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 VALID_PLANS = ['starter', 'professional', 'enterprise'] as const;
type PlanType = typeof VALID_PLANS[number];
const preSelectedPlan: PlanType | null = (planParam && VALID_PLANS.includes(planParam as PlanType)) ? planParam as PlanType : null;
const billingParam = searchParams.get('billing_cycle');
const urlBillingCycle = (billingParam === 'monthly' || billingParam === 'yearly') ? billingParam : null;
@@ -116,21 +113,21 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
const {
step,
setStep,
registrationState,
updateRegistrationState,
nextStep,
prevStep,
isSubmitting,
setIsSubmitting
isSubmitting
} = useRegistrationState({
initialPlan: preSelectedPlan || 'starter',
initialBillingCycle: urlBillingCycle || 'monthly',
isPilot: effectivePilotState // Use the effective pilot state
});
const [stripeInstance, setStripeInstance] = useState<Stripe | null>(null);
// paymentComplete is used for 3DS redirect recovery to prevent re-processing
const [paymentComplete, setPaymentComplete] = useState(false);
const [paymentData, setPaymentData] = useState<any>(null);
useEffect(() => {
const loadStripeInstance = async () => {
@@ -191,9 +188,9 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
updateRegistrationState({
basicInfo: savedState.basicInfo,
subscription: savedState.subscription,
paymentSetup: savedState.paymentSetup,
step: 'payment'
paymentSetup: savedState.paymentSetup
});
setStep('payment');
// Verify the SetupIntent status with our backend
const verificationResult = await verifySetupIntent(setupIntentParam);
@@ -227,6 +224,9 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
threeds_completed: true
};
// Complete the registration after 3DS verification
const result = await completeRegistrationAfterPayment(registrationData);
if (result.success) {
// Clean up saved state
sessionStorage.removeItem(savedStateKey);
@@ -238,10 +238,15 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
newUrl.searchParams.delete('redirect_status');
window.history.replaceState({}, '', newUrl.toString());
// Perform auto-login if tokens are returned
performAutoLogin(result);
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
// Mark payment as complete to prevent re-processing
setPaymentComplete(true);
// Navigate to onboarding
setTimeout(() => {
navigate('/app/onboarding');
}, 100);
@@ -261,13 +266,7 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
handle3DSRedirectRecovery();
}, [setupIntentParam, redirectStatusParam, stripeInstance, paymentComplete, redirectRecoveryInProgress]);
const handlePaymentComplete = (data: any) => {
setPaymentData(data);
setPaymentComplete(true);
nextStep();
};
const handleRegistrationSubmit = async () => {
try {
if (step === 'basic_info') {
@@ -279,13 +278,14 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
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
}
});
// Skip directly to payment step
setStep('payment');
} else {
// Go to subscription selection step
nextStep();
@@ -341,100 +341,22 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
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 steps = preSelectedPlan
? ['basic_info', 'payment']
: ['basic_info', 'subscription', 'payment'];
const currentIndex = steps.indexOf(step);
return currentIndex >= 0 ? ((currentIndex + 1) / steps.length) * 100 : 0;
};
@@ -445,15 +367,14 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
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'];
const progressSteps: RegistrationStep[] = preSelectedPlan
? ['basic_info', 'payment']
: ['basic_info', 'subscription', 'payment'];
// Render current step
const renderStep = () => {
@@ -489,37 +410,21 @@ export const RegistrationContainer: React.FC<RegistrationContainerProps> = ({
);
case 'payment':
return (
<Elements
stripe={stripeInstance}
<Elements
stripe={stripeInstance}
options={{
mode: 'setup',
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;
}

View File

@@ -6,7 +6,7 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox } from '../../ui';
import { Button } from '../../ui';
import { SubscriptionPricingCards } from '../../subscription/SubscriptionPricingCards';
import { subscriptionService } from '../../../api';
@@ -73,9 +73,14 @@ export const SubscriptionStep: React.FC<SubscriptionStepProps> = ({
fetchPlanMetadata();
// Automatically apply PILOT2025 coupon for pilot users with trial enabled
if (isPilot && useTrial && !couponCode) {
updateField('couponCode', 'PILOT2025');
// Automatically enable trial and apply PILOT2025 coupon for pilot users
if (isPilot) {
if (!useTrial) {
updateField('useTrial', true);
}
if (!couponCode) {
updateField('couponCode', 'PILOT2025');
}
}
return () => {
@@ -130,38 +135,14 @@ export const SubscriptionStep: React.FC<SubscriptionStepProps> = ({
mode="selection"
selectedPlan={selectedPlan}
onPlanSelect={(planKey) => updateField('planId', planKey)}
showPilotBanner={false} // Pilot program disabled as requested
pilotCouponCode={undefined}
pilotTrialMonths={0}
showPilotBanner={isPilot}
pilotCouponCode={isPilot ? 'PILOT2025' : undefined}
pilotTrialMonths={isPilot ? 3 : 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}>

View File

@@ -7,7 +7,7 @@
import { useState, useCallback } from 'react';
import { validateEmail } from '../../../../utils/validation';
export type RegistrationStep = 'basic_info' | 'subscription' | 'payment' | 'complete';
export type RegistrationStep = 'basic_info' | 'subscription' | 'payment';
export type BasicInfoData = {
firstName: string;
@@ -94,18 +94,17 @@ export const useRegistrationState = ({
switch (prev) {
case 'basic_info': return 'subscription';
case 'subscription': return 'payment';
case 'payment': return 'complete';
// Payment is the last step - no next step
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;
}
});
@@ -210,6 +209,7 @@ export const useRegistrationState = ({
return {
step,
setStep,
registrationState,
updateRegistrationState,
nextStep,

View File

@@ -118,6 +118,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
const [errors, setErrors] = useState<Record<string, string>>({});
const registerBakery = useRegisterBakery();
const registerBakeryWithSubscription = useRegisterBakeryWithSubscription();
const updateTenant = useUpdateTenant();
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
@@ -428,7 +429,7 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
<div className="flex justify-end pt-4 border-t-2 border-[var(--border-color)]/50">
<Button
onClick={handleSubmit}
isLoading={registerBakery.isPending || updateTenant.isPending}
isLoading={registerBakery.isPending || registerBakeryWithSubscription.isPending || updateTenant.isPending}
loadingText={t(tenantId
? 'onboarding:steps.tenant_registration.loading.updating'
: (isEnterprise