Add subcription feature 5
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
197
frontend/src/pages/public/ForgotPasswordPage.tsx
Normal file
197
frontend/src/pages/public/ForgotPasswordPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
313
frontend/src/pages/public/ResetPasswordPage.tsx
Normal file
313
frontend/src/pages/public/ResetPasswordPage.tsx
Normal 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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user