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

@@ -160,11 +160,20 @@ export const useChangePassword = (
});
};
export const useResetPassword = (
options?: UseMutationOptions<{ message: string }, ApiError, PasswordReset>
export const useRequestPasswordReset = (
options?: UseMutationOptions<{ message: string }, ApiError, string>
) => {
return useMutation<{ message: string }, ApiError, PasswordReset>({
mutationFn: (resetData: PasswordReset) => authService.resetPassword(resetData),
return useMutation<{ message: string }, ApiError, string>({
mutationFn: (email: string) => authService.requestPasswordReset(email),
...options,
});
};
export const useResetPasswordWithToken = (
options?: UseMutationOptions<{ message: string }, ApiError, { token: string; newPassword: string }>
) => {
return useMutation<{ message: string }, ApiError, { token: string; newPassword: string }>({
mutationFn: ({ token, newPassword }) => authService.resetPasswordWithToken(token, newPassword),
...options,
});
};

View File

@@ -440,7 +440,8 @@ export {
useRefreshToken,
useLogout,
useChangePassword,
useResetPassword,
useRequestPasswordReset,
useResetPasswordWithToken,
useUpdateProfile,
useVerifyEmail,
authKeys,

View File

@@ -93,8 +93,15 @@ export class AuthService {
return apiClient.post<{ message: string }>(`${this.baseUrl}/change-password`, passwordData);
}
async resetPassword(resetData: PasswordReset): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(`${this.baseUrl}/reset-password`, resetData);
async requestPasswordReset(email: string): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset-request`, { email });
}
async resetPasswordWithToken(token: string, newPassword: string): Promise<{ message: string }> {
return apiClient.post<{ message: string }>(`${this.baseUrl}/password/reset`, {
token,
new_password: newPassword
});
}
// ===================================================================

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

View File

@@ -130,7 +130,7 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
<div className={className}>
{/* Pilot Program Banner */}
{showPilotBanner && pilotCouponCode && (
<Card className="p-6 mb-8 bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500">
<Card className="p-6 mb-8 bg-[var(--bg-secondary)] border border-[var(--border-primary)]">
<div className="flex items-center gap-4">
<div className="flex-shrink-0">
<div className="w-14 h-14 bg-gradient-to-br from-amber-500 to-orange-500 rounded-full flex items-center justify-center shadow-lg">
@@ -138,14 +138,13 @@ export const SubscriptionPricingCards: React.FC<SubscriptionPricingCardsProps> =
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-amber-900 dark:text-amber-100 mb-1">
<h3 className="text-xl font-bold text-[var(--text-primary)] mb-1">
{t('ui.pilot_program_active')}
</h3>
<p className="text-sm text-amber-800 dark:text-amber-200"
<p className="text-sm text-[var(--text-secondary)]"
dangerouslySetInnerHTML={{
__html: t('ui.pilot_program_description', { count: pilotTrialMonths })
.replace('{count}', `<strong>${pilotTrialMonths}</strong>`)
.replace('20%', '<strong>20%</strong>')
}}
/>
</div>

View File

@@ -129,8 +129,8 @@ const Checkbox = forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, Che
'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
'border-[var(--border-tertiary)]', // Use tertiary border for better visibility when unchecked
'dark:border-[var(--text-tertiary)]', // Use text-tertiary in dark mode for better contrast
'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],

View File

@@ -7,6 +7,7 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
helperText?: string;
isRequired?: boolean;
isInvalid?: boolean;
isValid?: boolean;
size?: 'sm' | 'md' | 'lg';
variant?: 'outline' | 'filled' | 'unstyled';
leftIcon?: React.ReactNode;
@@ -23,6 +24,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
helperText,
isRequired = false,
isInvalid = false,
isValid = false,
size = 'md',
variant = 'outline',
leftIcon,
@@ -38,6 +40,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
}, ref) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
const hasError = isInvalid || !!error;
const hasSuccess = isValid && !hasError;
const baseInputClasses = [
'w-full transition-colors duration-200',
@@ -50,12 +53,14 @@ const Input = forwardRef<HTMLInputElement, InputProps>(({
outline: [
'bg-[var(--bg-primary)] border border-[var(--border-secondary)]',
'focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)]',
hasError ? 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]' : ''
hasError ? 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]' : '',
hasSuccess ? 'border-[var(--color-success)] focus:border-[var(--color-success)] focus:ring-[var(--color-success)]' : ''
],
filled: [
'bg-[var(--bg-secondary)] border border-transparent',
'focus:bg-[var(--bg-primary)] focus:border-[var(--color-primary)]',
hasError ? 'border-[var(--color-error)]' : ''
hasError ? 'border-[var(--color-error)]' : '',
hasSuccess ? 'border-[var(--color-success)]' : ''
],
unstyled: [
'bg-transparent border-none',

View File

@@ -87,7 +87,7 @@
},
"cta": {
"title": "Be Part of Our Story",
"subtitle": "We're looking for 20 pioneering bakeries for our pilot program. 3 months free, 20% lifetime discount, and the opportunity to shape the product.",
"subtitle": "We're looking for 20 pioneering bakeries for our pilot program. 3 months free and the opportunity to shape the product.",
"primary": "Request Pilot Spot",
"secondary": "See Demo"
}

View File

@@ -6,7 +6,7 @@
},
"cta": {
"title": "Ready to Optimize Your Bakery?",
"description": "Join the pilot program and get 3 months free + 20% lifetime discount",
"description": "Join the pilot program and get 3 months free",
"button": "Request Pilot Spot"
},
"post": {

View File

@@ -156,7 +156,7 @@
{
"category": "pricing",
"question": "How much does the pilot program cost?",
"answer": "The pilot program is FREE for the first 3 months. After that, you pay only €49/month with a lifetime 20% discount (regular price: €79/month). No commitment contracts, cancel anytime."
"answer": "The pilot program is FREE for the first 3 months. No commitment contracts, cancel anytime."
},
{
"category": "technical",

View File

@@ -182,7 +182,7 @@
"pilot_period": "First 3 months (pilot)",
"regular_price": "€99/month",
"pilot_discount_price": "€79/month",
"pilot_discount_note": "20% lifetime discount if you were a pilot",
"pilot_discount_note": "Pilot program",
"features": {
"prediction": "AI demand prediction",
"inventory": "Automatic inventory management",

View File

@@ -120,7 +120,7 @@
"error_loading": "Could not load plans. Please try again.",
"most_popular": "Most Popular",
"pilot_program_active": "Pilot Program Active",
"pilot_program_description": "As a pilot program participant, you get {count} completely free months on the plan you choose, plus a lifetime 20% discount if you decide to continue.",
"pilot_program_description": "As a pilot program participant, you get {count} completely free months on the plan you choose.",
"per_month": "per month",
"per_year": "per year",
"save_amount": "Save {amount}/year",

View File

@@ -87,7 +87,7 @@
},
"cta": {
"title": "Sé Parte de Nuestra Historia",
"subtitle": "Estamos buscando 20 panaderías pioneras para nuestro programa piloto. 3 meses gratis, 20% descuento de por vida, y la oportunidad de moldear el producto.",
"subtitle": "Estamos buscando 20 panaderías pioneras para nuestro programa piloto. 3 meses gratis y la oportunidad de moldear el producto.",
"primary": "Solicitar Plaza en el Piloto",
"secondary": "Ver Demo"
}

View File

@@ -6,7 +6,7 @@
},
"cta": {
"title": "¿Listo para Optimizar tu Panadería?",
"description": "Únete al programa piloto y obtén 3 meses gratis + 20% descuento de por vida",
"description": "Únete al programa piloto y obtén 3 meses gratis",
"button": "Solicitar Plaza en el Piloto"
},
"post": {

View File

@@ -1604,7 +1604,7 @@
{
"category": "pricing",
"question": "¿Cuánto cuesta el programa piloto?",
"answer": "El programa piloto es GRATIS durante los primeros 3 meses. Después, pagas solo €49/mes con un 20% de descuento de por vida (precio normal: €79/mes). Sin contratos de permanencia, cancela cuando quieras."
"answer": "El programa piloto es GRATIS durante los primeros 3 meses. Sin contratos de permanencia, cancela cuando quieras."
},
{
"category": "pricing",

View File

@@ -182,7 +182,7 @@
"pilot_period": "Primeros 3 meses (piloto)",
"regular_price": "€99/mes",
"pilot_discount_price": "€79/mes",
"pilot_discount_note": "20% descuento de por vida si fuiste piloto",
"pilot_discount_note": "Programa piloto",
"features": {
"prediction": "Predicción de demanda con IA",
"inventory": "Gestión automática de inventario",

View File

@@ -120,7 +120,7 @@
"error_loading": "No se pudieron cargar los planes. Por favor, intenta nuevamente.",
"most_popular": "Más Popular",
"pilot_program_active": "Programa Piloto Activo",
"pilot_program_description": "Como participante del programa piloto, obtienes {count} meses completamente gratis en el plan que elijas, más un 20% de descuento de por vida si decides continuar.",
"pilot_program_description": "Como participante del programa piloto, obtienes {count} meses completamente gratis en el plan que elijas.",
"per_month": "por mes",
"per_year": "por año",
"save_amount": "Ahorra {amount}/año",

View File

@@ -87,7 +87,7 @@
},
"cta": {
"title": "Izan Gure Historiaren Parte",
"subtitle": "20 okindegi aitzindari bilatzen ari gara gure pilotu-programarako. 3 hilabete doan, %20ko deskontua bizitza osoan, eta produktua moldatzeko aukera.",
"subtitle": "20 okindegi aitzindari bilatzen ari gara gure pilotu-programarako. 3 hilabete doan eta produktua moldatzeko aukera.",
"primary": "Eskatu Pilotuko Plaza",
"secondary": "Ikusi Demoa"
}

View File

@@ -6,7 +6,7 @@
},
"cta": {
"title": "Prest Zaude Zure Okindegi Optimizatzeko?",
"description": "Batu pilotu programara eta lortu 3 hilabete doan + % 20ko beherapena betiko",
"description": "Batu pilotu programara eta lortu 3 hilabete doan",
"button": "Eskatu Pilotuko Plaza"
},
"post": {

View File

@@ -156,7 +156,7 @@
{
"category": "pricing",
"question": "Zenbat kostatzen du pilotu-programak?",
"answer": "Pilotu-programa DOAN dago lehenengo 3 hilabeteetan. Gero, 49€/hilean ordaintzen duzu bizitza osoko %20ko deskontua (prezio arrunta: 79€/hilean). Ez dago konpromiso-kontraturik, utzi nahi duzunean."
"answer": "Pilotu-programa DOAN dago lehenengo 3 hilabeteetan. Ez dago konpromiso-kontraturik, utzi nahi duzunean."
},
{
"category": "technical",

View File

@@ -181,7 +181,7 @@
"pilot_period": "Lehenengo 3 hilabeteak (pilotua)",
"regular_price": "€99/hilean",
"pilot_discount_price": "€79/hilean",
"pilot_discount_note": "%20ko deskontua bizitza osoan piloto izan bazinen",
"pilot_discount_note": "Pilotu programa",
"features": {
"prediction": "IArekin eskariaren aurreikuspena",
"inventory": "Inbentarioaren kudeaketa automatikoa",

View File

@@ -117,7 +117,7 @@
"error_loading": "Ezin izan dira planak kargatu. Mesedez, saiatu berriro.",
"most_popular": "Ezagunena",
"pilot_program_active": "Programa Piloto Aktiboa",
"pilot_program_description": "Programa pilotoko parte-hartzaile gisa, aukeratzen duzun planean {count} hilabete guztiz doakoak lortzen dituzu, gehi bizitza osorako %20ko deskontua jarraitzea erabakitzen baduzu.",
"pilot_program_description": "Programa pilotoko parte-hartzaile gisa, aukeratzen duzun planean {count} hilabete guztiz doakoak lortzen dituzu.",
"per_month": "hileko",
"per_year": "urteko",
"save_amount": "Aurreztu {amount}/urtean",

View File

@@ -33,13 +33,15 @@ const SubscriptionStatusBanner: React.FC<{
isTrial?: boolean;
trialEndsAt?: string;
trialDaysRemaining?: number;
}> = ({ planName, status, nextBilling, cost, onUpgrade, onManagePayment, showUpgradeButton, isTrial, trialEndsAt, trialDaysRemaining }) => {
isPilot?: boolean;
}> = ({ planName, status, nextBilling, cost, onUpgrade, onManagePayment, showUpgradeButton, isTrial, trialEndsAt, trialDaysRemaining, isPilot }) => {
// Determine if we should show trial information
const showTrialInfo = isTrial && trialEndsAt;
const showPilotInfo = isPilot;
return (
<Card className={`p-6 mb-6 ${showTrialInfo
<Card className={`p-6 mb-6 ${showTrialInfo || showPilotInfo
? 'bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-900/30 dark:to-cyan-900/30 border-2 border-blue-300 dark:border-blue-600'
: 'bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-900/20 dark:to-purple-900/20 border-2 border-blue-200 dark:border-blue-700'}`}
>
@@ -47,27 +49,29 @@ const SubscriptionStatusBanner: React.FC<{
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Crown className="w-5 h-5 text-yellow-500" />
{planName} {showTrialInfo && <span className="text-blue-600 dark:text-blue-400">(Prueba Gratis)</span>}
{planName} {showPilotInfo && <span className="text-blue-600 dark:text-blue-400">(Programa Piloto)</span>}
{!showPilotInfo && showTrialInfo && <span className="text-blue-600 dark:text-blue-400">(Prueba Gratis)</span>}
</h3>
<Badge variant={showTrialInfo ? 'info' : status === 'active' ? 'success' : 'default'} className="mt-2">
{showTrialInfo ? `En Prueba (${trialDaysRemaining} días)` : status === 'active' ? 'Activo' : status}
<Badge variant={showPilotInfo ? 'info' : showTrialInfo ? 'info' : status === 'active' ? 'success' : 'default'} className="mt-2">
{showPilotInfo ? 'Piloto Activo' : showTrialInfo ? `En Prueba (${trialDaysRemaining} días)` : status === 'active' ? 'Activo' : status}
</Badge>
</div>
<div className="text-center">
<p className="text-sm text-[var(--text-secondary)]">{showTrialInfo ? 'Fin de la Prueba' : 'Próxima Facturación'}</p>
<p className="font-semibold text-[var(--text-primary)]">{showTrialInfo ? trialEndsAt : nextBilling}</p>
<p className="text-sm text-[var(--text-secondary)]">{showPilotInfo ? 'Fin del Piloto' : showTrialInfo ? 'Fin de la Prueba' : 'Próxima Facturación'}</p>
<p className="font-semibold text-[var(--text-primary)]">{showPilotInfo ? trialEndsAt : showTrialInfo ? trialEndsAt : nextBilling}</p>
</div>
<div className="text-center">
<p className="text-sm text-[var(--text-secondary)]">Costo Mensual</p>
<p className="font-semibold text-[var(--text-primary)] text-lg">
{cost} {showTrialInfo && <span className="text-xs text-blue-600 dark:text-blue-400">(después de la prueba)</span>}
{cost} {showPilotInfo && <span className="text-xs text-blue-600 dark:text-blue-400">(después del periodo piloto)</span>}
{!showPilotInfo && showTrialInfo && <span className="text-xs text-blue-600 dark:text-blue-400">(después de la prueba)</span>}
</p>
</div>
<div className="flex flex-col gap-2">
{showTrialInfo ? (
{showPilotInfo || showTrialInfo ? (
<>
<Button onClick={onUpgrade} variant="primary" size="sm" className="w-full bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700">
<ArrowRight className="w-4 h-4 mr-2" />
@@ -94,9 +98,9 @@ const SubscriptionStatusBanner: React.FC<{
)}
</div>
</div>
{/* Trial Countdown Banner */}
{showTrialInfo && (
{/* Pilot/Trial Countdown Banner */}
{(showPilotInfo || showTrialInfo) && (
<div className="mt-4 p-4 bg-gradient-to-r from-blue-600/10 to-cyan-600/10 border border-blue-300/50 rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/20 rounded-lg border border-blue-500/30 flex-shrink-0">
@@ -104,7 +108,10 @@ const SubscriptionStatusBanner: React.FC<{
</div>
<div className="flex-1">
<p className="text-sm font-medium text-blue-700 dark:text-blue-300">
¡Disfruta de tu periodo de prueba! Tu acceso gratuito termina en <strong>{trialDaysRemaining} días</strong>.
{showPilotInfo
? `¡Eres parte de nuestro programa piloto! Tu acceso gratuito termina en <strong>${trialDaysRemaining} días</strong>.`
: `¡Disfruta de tu periodo de prueba! Tu acceso gratuito termina en <strong>${trialDaysRemaining} días</strong>.`
}
</p>
</div>
<Button onClick={onUpgrade} variant="ghost" size="sm" className="text-blue-600 dark:text-blue-400 hover:bg-blue-500/10">
@@ -632,6 +639,15 @@ const SubscriptionPageRedesign: React.FC = () => {
// Check if there are any high usage metrics
const hasHighUsageMetrics = forecastUsage && forecastUsage.highUsageMetrics && forecastUsage.highUsageMetrics.length > 0;
// Determine if this is a pilot subscription based on characteristics
// Pilot subscriptions have extended trial periods (typically 90 days from PILOT2025 coupon)
// compared to regular trials (typically 14 days), so we check for longer trial periods
const isPilotSubscription = usageSummary.status === 'trialing' &&
usageSummary.trial_ends_at &&
new Date(usageSummary.trial_ends_at) > new Date() &&
// Check if trial period is longer than typical trial (e.g., > 60 days indicates pilot)
(new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) > (60 * 24 * 60 * 60 * 1000); // 60+ days
return (
<div className="p-6 space-y-6">
<PageHeader
@@ -643,20 +659,21 @@ const SubscriptionPageRedesign: React.FC = () => {
<SubscriptionStatusBanner
planName={usageSummary.plan.charAt(0).toUpperCase() + usageSummary.plan.slice(1)}
status={usageSummary.status}
nextBilling={new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
nextBilling={new Date(usageSummary.next_billing_date).toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
cost={subscriptionService.formatPrice(usageSummary.monthly_price)}
onUpgrade={() => setShowPlans(true)}
onManagePayment={() => setPaymentMethodModalOpen(true)}
showUpgradeButton={usageSummary.plan !== 'enterprise'}
isTrial={usageSummary.status === 'trialing'}
trialEndsAt={usageSummary.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
isTrial={usageSummary.status === 'trialing' || isPilotSubscription}
isPilot={isPilotSubscription}
trialEndsAt={usageSummary.trial_ends_at ? new Date(usageSummary.trial_ends_at).toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
}) : undefined}
trialDaysRemaining={usageSummary.trial_ends_at ? Math.max(0, Math.ceil((new Date(usageSummary.trial_ends_at).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))) : undefined}
/>

View File

@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../../components/ui';
import { useRequestPasswordReset } from '../../api/hooks/auth';
import { showToast } from '../../utils/toast';
import { validateEmail } from '../../utils/validation';
import { PublicLayout } from '../../components/layout';
const ForgotPasswordPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<{ email?: string }>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const { mutate: requestPasswordReset } = useRequestPasswordReset({
onSuccess: () => {
showToast.success(
'Si una cuenta con este email existe, recibirás un enlace de recuperación.',
{ title: 'Instrucciones enviadas' }
);
setIsSubmitted(true);
},
onError: (error: any) => {
const errorMessage = error.response?.data?.detail ||
'Hubo un problema al enviar las instrucciones de recuperación.';
showToast.error(errorMessage, { title: 'Error' });
},
onSettled: () => {
setIsSubmitting(false);
}
});
const validateForm = (): boolean => {
const newErrors: { email?: string } = {};
const emailValidation = validateEmail(email);
if (!emailValidation.isValid) {
newErrors.email = t('auth:validation.email_invalid', 'Por favor, ingrese un email válido');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
requestPasswordReset(email);
};
const handleGoBackToLogin = () => {
navigate('/login');
};
if (isSubmitted) {
return (
<PublicLayout
variant="centered"
maxWidth="md"
headerProps={{
showThemeToggle: true,
showAuthButtons: false,
showLanguageSelector: true,
variant: "minimal"
}}
>
<Card className="p-8 w-full max-w-md mx-auto" role="main">
<div className="text-center mb-8">
<div className="mx-auto bg-color-success/10 p-3 rounded-full w-16 h-16 flex items-center justify-center mb-4">
<svg
className="w-8 h-8 text-color-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">
{t('auth:forgot_password.submitted_title', '¡Instrucciones Enviadas!')}
</h1>
<p className="text-text-secondary">
{t('auth:forgot_password.submitted_message', 'Hemos enviado un enlace de recuperación a tu correo electrónico.')}
</p>
</div>
<div className="space-y-4">
<Button
variant="primary"
size="lg"
isFullWidth
onClick={handleGoBackToLogin}
className="mt-4"
>
Volver al Inicio de Sesión
</Button>
</div>
</Card>
</PublicLayout>
);
}
return (
<PublicLayout
variant="centered"
maxWidth="md"
headerProps={{
showThemeToggle: true,
showAuthButtons: false,
showLanguageSelector: true,
variant: "minimal"
}}
>
<Card className="p-8 w-full max-w-md mx-auto" role="main">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-text-primary mb-2">
{t('auth:forgot_password.title', '¿Olvidaste tu contraseña?')}
</h1>
<p className="text-text-secondary">
{t('auth:forgot_password.subtitle', 'Ingresa tu correo electrónico y te enviaremos un enlace para restablecer tu contraseña.')}
</p>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6"
noValidate
aria-label={t('auth:forgot_password.title', 'Formulario de recuperación de contraseña')}
>
<div>
<Input
type="email"
label={t('auth:login.email', 'Correo Electrónico')}
placeholder="tu.email@panaderia.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={errors.email}
disabled={isSubmitting}
autoComplete="email"
required
aria-describedby={errors.email ? 'email-error' : undefined}
leftIcon={
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
}
/>
{errors.email && (
<p id="email-error" className="text-color-error text-sm mt-1" role="alert">
{errors.email}
</p>
)}
</div>
<Button
type="submit"
variant="primary"
size="lg"
isFullWidth
isLoading={isSubmitting}
loadingText="Enviando instrucciones..."
disabled={isSubmitting}
>
{isSubmitting ? 'Enviando...' : 'Enviar Instrucciones'}
</Button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleGoBackToLogin}
className="text-sm text-color-primary hover:text-color-primary-dark transition-colors underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-offset-2 rounded"
>
Volver al Inicio de Sesión
</button>
</div>
</Card>
</PublicLayout>
);
};
export default ForgotPasswordPage;

View File

@@ -30,8 +30,12 @@ const LoginPage: React.FC = () => {
navigate('/register');
};
const handleForgotPasswordClick = () => {
navigate('/forgot-password');
};
return (
<PublicLayout
<PublicLayout
variant="centered"
maxWidth="md"
headerProps={{
@@ -44,6 +48,7 @@ const LoginPage: React.FC = () => {
<LoginForm
onSuccess={handleLoginSuccess}
onRegisterClick={handleRegisterClick}
onForgotPasswordClick={handleForgotPasswordClick}
className="mx-auto"
/>
</PublicLayout>

View File

@@ -0,0 +1,313 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../../components/ui';
import { useResetPasswordWithToken } from '../../api/hooks/auth';
import { showToast } from '../../utils/toast';
import { PublicLayout } from '../../components/layout';
const ResetPasswordPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{ password?: string; confirmPassword?: string }>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const token = searchParams.get('token');
useEffect(() => {
if (!token) {
navigate('/forgot-password');
showToast.error('Token de restablecimiento inválido o ausente.', { title: 'Error' });
}
}, [token, navigate]);
const { mutate: resetPasswordWithToken } = useResetPasswordWithToken({
onSuccess: () => {
showToast.success('Tu contraseña ha sido restablecida exitosamente.', {
title: '¡Contraseña Actualizada!'
});
setIsSubmitted(true);
},
onError: (error: any) => {
const errorMessage = error.response?.data?.detail ||
'Hubo un problema al restablecer tu contraseña.';
showToast.error(errorMessage, { title: 'Error' });
},
onSettled: () => {
setIsSubmitting(false);
}
});
const validateForm = (): boolean => {
const newErrors: { password?: string; confirmPassword?: string } = {};
if (!password) {
newErrors.password = 'La contraseña es requerida';
} else if (password.length < 8) {
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
} else if (!/(?=.*[a-z])/.test(password)) {
newErrors.password = 'La contraseña debe contener al menos una letra minúscula';
} else if (!/(?=.*[A-Z])/.test(password)) {
newErrors.password = 'La contraseña debe contener al menos una letra mayúscula';
} else if (!/(?=.*\d)/.test(password)) {
newErrors.password = 'La contraseña debe contener al menos un número';
}
if (!confirmPassword) {
newErrors.confirmPassword = 'Confirma tu nueva contraseña';
} else if (password !== confirmPassword) {
newErrors.confirmPassword = 'Las contraseñas no coinciden';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm() || !token) {
return;
}
setIsSubmitting(true);
resetPasswordWithToken({ token, newPassword: password });
};
const handleGoToLogin = () => {
navigate('/login');
};
if (isSubmitted) {
return (
<PublicLayout
variant="centered"
maxWidth="md"
headerProps={{
showThemeToggle: true,
showAuthButtons: false,
showLanguageSelector: true,
variant: "minimal"
}}
>
<Card className="p-8 w-full max-w-md mx-auto" role="main">
<div className="text-center mb-8">
<div className="mx-auto bg-color-success/10 p-3 rounded-full w-16 h-16 flex items-center justify-center mb-4">
<svg
className="w-8 h-8 text-color-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-text-primary mb-2">
{t('auth:reset_password.success_title', '¡Contraseña Restablecida!')}
</h1>
<p className="text-text-secondary">
{t('auth:reset_password.success_message', 'Tu contraseña ha sido actualizada exitosamente.')}
</p>
</div>
<div className="space-y-4">
<Button
variant="primary"
size="lg"
isFullWidth
onClick={handleGoToLogin}
>
Iniciar Sesión
</Button>
</div>
</Card>
</PublicLayout>
);
}
return (
<PublicLayout
variant="centered"
maxWidth="md"
headerProps={{
showThemeToggle: true,
showAuthButtons: false,
showLanguageSelector: true,
variant: "minimal"
}}
>
<Card className="p-8 w-full max-w-md mx-auto" role="main">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-text-primary mb-2">
{t('auth:reset_password.title', 'Restablecer Contraseña')}
</h1>
<p className="text-text-secondary">
{t('auth:reset_password.subtitle', 'Ingresa tu nueva contraseña.')}
</p>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6"
noValidate
aria-label={t('auth:reset_password.title', 'Formulario de restablecimiento de contraseña')}
>
<div>
<Input
type={showPassword ? 'text' : 'password'}
label="Nueva Contraseña"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
error={errors.password}
disabled={isSubmitting}
required
aria-describedby={errors.password ? 'password-error' : undefined}
leftIcon={
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-text-secondary hover:text-text-primary transition-colors"
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
tabIndex={0}
>
{showPassword ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
}
/>
{errors.password && (
<p id="password-error" className="text-color-error text-sm mt-1" role="alert">
{errors.password}
</p>
)}
</div>
<div>
<Input
type={showConfirmPassword ? 'text' : 'password'}
label="Confirmar Nueva Contraseña"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={errors.confirmPassword}
disabled={isSubmitting}
required
aria-describedby={errors.confirmPassword ? 'confirm-password-error' : undefined}
leftIcon={
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
}
rightIcon={
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="text-text-secondary hover:text-text-primary transition-colors"
aria-label={showConfirmPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
tabIndex={0}
>
{showConfirmPassword ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
}
/>
{errors.confirmPassword && (
<p id="confirm-password-error" className="text-color-error text-sm mt-1" role="alert">
{errors.confirmPassword}
</p>
)}
</div>
<Button
type="submit"
variant="primary"
size="lg"
isFullWidth
isLoading={isSubmitting}
loadingText="Actualizando contraseña..."
disabled={isSubmitting}
>
{isSubmitting ? 'Actualizando...' : 'Actualizar Contraseña'}
</Button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={handleGoToLogin}
className="text-sm text-color-primary hover:text-color-primary-dark transition-colors underline-offset-2 hover:underline focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-offset-2 rounded"
>
Volver al Inicio de Sesión
</button>
</div>
</Card>
</PublicLayout>
);
};
export default ResetPasswordPage;

View File

@@ -12,4 +12,6 @@ export { default as CareersPage } from './CareersPage';
export { default as HelpCenterPage } from './HelpCenterPage';
export { default as DocumentationPage } from './DocumentationPage';
export { default as ContactPage } from './ContactPage';
export { default as FeedbackPage } from './FeedbackPage';
export { default as FeedbackPage } from './FeedbackPage';
export { default as ForgotPasswordPage } from './ForgotPasswordPage';
export { default as ResetPasswordPage } from './ResetPasswordPage';

View File

@@ -23,6 +23,8 @@ const DocumentationPage = React.lazy(() => import('../pages/public/Documentation
const ContactPage = React.lazy(() => import('../pages/public/ContactPage'));
const FeedbackPage = React.lazy(() => import('../pages/public/FeedbackPage'));
const UnauthorizedPage = React.lazy(() => import('../pages/public/UnauthorizedPage'));
const ForgotPasswordPage = React.lazy(() => import('../pages/public/ForgotPasswordPage'));
const ResetPasswordPage = React.lazy(() => import('../pages/public/ResetPasswordPage'));
const DashboardPage = React.lazy(() => import('../pages/app/DashboardPage'));
// Operations pages
@@ -98,6 +100,8 @@ export const AppRouter: React.FC = () => {
<Route path="/cookie-preferences" element={<CookiePreferencesPage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="/401" element={<UnauthorizedPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* Protected Routes with AppShell Layout */}
<Route

View File

@@ -222,6 +222,32 @@ export const routesConfig: RouteConfig[] = [
hideSidebar: true,
},
},
{
path: ROUTES.FORGOT_PASSWORD,
name: 'ForgotPassword',
component: 'ForgotPasswordPage',
title: 'Recuperar Contraseña',
requiresAuth: false,
showInNavigation: false,
meta: {
layout: 'auth',
hideHeader: true,
hideSidebar: true,
},
},
{
path: ROUTES.RESET_PASSWORD,
name: 'ResetPassword',
component: 'ResetPasswordPage',
title: 'Restablecer Contraseña',
requiresAuth: false,
showInNavigation: false,
meta: {
layout: 'auth',
hideHeader: true,
hideSidebar: true,
},
},
{
path: ROUTES.FEATURES,
name: 'Features',