Start integrating the onboarding flow with backend 1
This commit is contained in:
@@ -56,8 +56,6 @@ export const LoginForm: React.FC<LoginFormProps> = ({
|
||||
|
||||
if (!credentials.password) {
|
||||
newErrors.password = 'La contraseña es requerida';
|
||||
} else if (credentials.password.length < 6) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 6 caracteres';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button, Input, Card } from '../../ui';
|
||||
import { useAuth } from '../../../hooks/api/useAuth';
|
||||
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||
import { useAuthActions } from '../../../stores/auth.store';
|
||||
import { useToast } from '../../../hooks/ui/useToast';
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
@@ -33,7 +34,10 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { requestPasswordReset, resetPassword, isLoading, error } = useAuth();
|
||||
// TODO: Implement password reset in Zustand auth store
|
||||
// const { requestPasswordReset, resetPassword, isLoading, error } = useAuth();
|
||||
const isLoading = false;
|
||||
const error = null;
|
||||
const { showToast } = useToast();
|
||||
|
||||
const isResetMode = Boolean(token) || mode === 'reset';
|
||||
@@ -109,12 +113,11 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
||||
|
||||
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])(?=.*[A-Z])(?=.*\d)/.test(password)) {
|
||||
newErrors.password = 'La contraseña debe contener mayúsculas, minúsculas y números';
|
||||
} else if (passwordStrength < 50) {
|
||||
newErrors.password = 'La contraseña es demasiado débil. Intenta con una más segura';
|
||||
} else {
|
||||
const passwordErrors = getPasswordErrors(password);
|
||||
if (passwordErrors.length > 0) {
|
||||
newErrors.password = passwordErrors[0]; // Show first error
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
@@ -147,7 +150,9 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await requestPasswordReset(email);
|
||||
// TODO: Implement password reset request
|
||||
// const success = await requestPasswordReset(email);
|
||||
const success = false; // Placeholder
|
||||
if (success) {
|
||||
setIsEmailSent(true);
|
||||
showToast({
|
||||
@@ -192,7 +197,9 @@ export const PasswordResetForm: React.FC<PasswordResetFormProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await resetPassword(token, password);
|
||||
// TODO: Implement password reset
|
||||
// const success = await resetPassword(token, password);
|
||||
const success = false; // Placeholder
|
||||
if (success) {
|
||||
showToast({
|
||||
type: 'success',
|
||||
|
||||
@@ -231,10 +231,19 @@ export const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
||||
|
||||
if (!passwordData.newPassword) {
|
||||
newErrors.newPassword = 'La nueva contraseña es requerida';
|
||||
} else if (passwordData.newPassword.length < 8) {
|
||||
newErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres';
|
||||
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(passwordData.newPassword)) {
|
||||
newErrors.newPassword = 'La contraseña debe contener mayúsculas, minúsculas y números';
|
||||
} else {
|
||||
// Use simpler validation for now to match backend exactly
|
||||
if (passwordData.newPassword.length < 8) {
|
||||
newErrors.newPassword = 'La contraseña debe tener al menos 8 caracteres';
|
||||
} else if (passwordData.newPassword.length > 128) {
|
||||
newErrors.newPassword = 'La contraseña no puede exceder 128 caracteres';
|
||||
} else if (!/[A-Z]/.test(passwordData.newPassword)) {
|
||||
newErrors.newPassword = 'La contraseña debe contener al menos una letra mayúscula';
|
||||
} else if (!/[a-z]/.test(passwordData.newPassword)) {
|
||||
newErrors.newPassword = 'La contraseña debe contener al menos una letra minúscula';
|
||||
} else if (!/\d/.test(passwordData.newPassword)) {
|
||||
newErrors.newPassword = 'La contraseña debe contener al menos un número';
|
||||
}
|
||||
}
|
||||
|
||||
if (!passwordData.confirmNewPassword) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Input, Card } from '../../ui';
|
||||
import { useAuth } from '../../../hooks/api/useAuth';
|
||||
import { UserRegistration } from '../../../types/auth.types';
|
||||
import { PasswordCriteria, validatePassword, getPasswordErrors } from '../../ui/PasswordCriteria';
|
||||
import { useAuthActions, useAuthLoading, useAuthError } from '../../../stores/auth.store';
|
||||
import { useToast } from '../../../hooks/ui/useToast';
|
||||
import { isMockRegistration } from '../../../config/mock.config';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -36,9 +35,20 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const { register, isLoading, error } = useAuth();
|
||||
const { register } = useAuthActions();
|
||||
const isLoading = useAuthLoading();
|
||||
const error = useAuthError();
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
// Helper function to determine password match status
|
||||
const getPasswordMatchStatus = () => {
|
||||
if (!formData.confirmPassword) return 'empty';
|
||||
if (formData.password === formData.confirmPassword) return 'match';
|
||||
return 'mismatch';
|
||||
};
|
||||
|
||||
const passwordMatchStatus = getPasswordMatchStatus();
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<SimpleUserRegistration> = {};
|
||||
|
||||
@@ -56,8 +66,11 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es requerida';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
||||
} else {
|
||||
const passwordErrors = getPasswordErrors(formData.password);
|
||||
if (passwordErrors.length > 0) {
|
||||
newErrors.password = passwordErrors[0]; // Show first error
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
@@ -76,64 +89,28 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log('Form submitted, mock mode:', isMockRegistration());
|
||||
|
||||
// FORCED MOCK MODE FOR TESTING - Always bypass for now
|
||||
const FORCE_MOCK = true;
|
||||
if (FORCE_MOCK || isMockRegistration()) {
|
||||
console.log('Mock registration triggered, showing toast and calling onSuccess');
|
||||
// Show immediate success notification
|
||||
try {
|
||||
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
|
||||
title: 'Cuenta creada exitosamente'
|
||||
});
|
||||
console.log('Toast shown, calling onSuccess callback');
|
||||
} catch (error) {
|
||||
console.error('Error showing toast:', error);
|
||||
// Fallback: show browser alert if toast fails
|
||||
alert('¡Cuenta creada exitosamente! Redirigiendo al onboarding...');
|
||||
}
|
||||
|
||||
// Call success immediately (removing delay for easier testing)
|
||||
try {
|
||||
onSuccess?.();
|
||||
console.log('onSuccess called');
|
||||
} catch (error) {
|
||||
console.error('Error calling onSuccess:', error);
|
||||
// Fallback: direct redirect if callback fails
|
||||
window.location.href = '/app/onboarding';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registrationData: UserRegistration = {
|
||||
const registrationData = {
|
||||
full_name: formData.full_name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
tenant_name: 'Default Bakery', // Default value since we're not collecting it
|
||||
phone: '' // Optional field
|
||||
};
|
||||
|
||||
const success = await register(registrationData);
|
||||
await register(registrationData);
|
||||
|
||||
if (success) {
|
||||
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
|
||||
title: 'Cuenta creada exitosamente'
|
||||
});
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
|
||||
title: 'Error al crear la cuenta'
|
||||
});
|
||||
}
|
||||
showSuccessToast('¡Bienvenido! Tu cuenta ha sido creada correctamente.', {
|
||||
title: 'Cuenta creada exitosamente'
|
||||
});
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
showErrorToast('No se pudo conectar con el servidor. Verifica tu conexión a internet.', {
|
||||
title: 'Error de conexión'
|
||||
showErrorToast(error || 'No se pudo crear la cuenta. Verifica que el email no esté en uso.', {
|
||||
title: 'Error al crear la cuenta'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -199,6 +176,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
disabled={isLoading}
|
||||
maxLength={128}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
leftIcon={
|
||||
@@ -227,21 +205,49 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
label="Confirmar Contraseña"
|
||||
placeholder="Repite tu contraseña"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
disabled={isLoading}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
leftIcon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
{/* Password Criteria - Show when user is typing */}
|
||||
{formData.password && (
|
||||
<PasswordCriteria
|
||||
password={formData.password}
|
||||
className="mt-2"
|
||||
showOnlyFailed={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
label="Confirmar Contraseña"
|
||||
placeholder="Repite tu contraseña"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange('confirmPassword')}
|
||||
error={errors.confirmPassword}
|
||||
disabled={isLoading}
|
||||
maxLength={128}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className={
|
||||
passwordMatchStatus === 'match' && formData.confirmPassword
|
||||
? 'border-color-success focus:border-color-success ring-color-success'
|
||||
: passwordMatchStatus === 'mismatch' && formData.confirmPassword
|
||||
? 'border-color-error focus:border-color-error ring-color-error'
|
||||
: ''
|
||||
}
|
||||
leftIcon={
|
||||
passwordMatchStatus === 'match' && formData.confirmPassword ? (
|
||||
<svg className="w-5 h-5 text-color-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : passwordMatchStatus === 'mismatch' && formData.confirmPassword ? (
|
||||
<svg className="w-5 h-5 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 0h12a2 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"
|
||||
@@ -262,6 +268,32 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Match Status Message */}
|
||||
{formData.confirmPassword && (
|
||||
<div className="mt-2 transition-all duration-300 ease-in-out">
|
||||
{passwordMatchStatus === 'match' ? (
|
||||
<div className="flex items-center space-x-2 text-color-success animate-fade-in">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-success/10 flex items-center justify-center">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">¡Las contraseñas coinciden!</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-color-error animate-fade-in">
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-color-error/10 flex items-center justify-center">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Las contraseñas no coinciden</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-border-primary">
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -292,7 +324,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
loadingText="Creando cuenta..."
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !validatePassword(formData.password) || !formData.acceptTerms || passwordMatchStatus !== 'match'}
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
console.log('Button clicked!');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, Button, Input, Select, Badge } from '../../ui';
|
||||
import { tenantService } from '../../../services/api/tenant.service';
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
import { useTenantActions } from '../../../stores/tenant.store';
|
||||
|
||||
|
||||
export interface OnboardingStep {
|
||||
id: string;
|
||||
@@ -37,6 +41,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
const [stepData, setStepData] = useState<Record<string, any>>({});
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const { setCurrentTenant } = useTenantActions();
|
||||
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
@@ -80,41 +85,55 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
const data = stepData[currentStep.id] || {};
|
||||
|
||||
if (!data.bakery?.tenant_id) {
|
||||
// Create tenant inline
|
||||
// Create tenant inline using real backend API
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: { ...data.bakery, isCreating: true }
|
||||
});
|
||||
|
||||
try {
|
||||
const mockTenantService = {
|
||||
createTenant: async (formData: any) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
tenant_id: `tenant_${Date.now()}`,
|
||||
name: formData.name,
|
||||
...formData
|
||||
};
|
||||
}
|
||||
// Use the backend-compatible data directly from BakerySetupStep
|
||||
const bakeryRegistration = {
|
||||
name: data.bakery.name,
|
||||
address: data.bakery.address,
|
||||
city: data.bakery.city,
|
||||
postal_code: data.bakery.postal_code,
|
||||
phone: data.bakery.phone,
|
||||
business_type: data.bakery.business_type,
|
||||
business_model: data.bakery.business_model
|
||||
};
|
||||
|
||||
const tenantData = await mockTenantService.createTenant(data.bakery);
|
||||
const response = await tenantService.createTenant(bakeryRegistration);
|
||||
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: {
|
||||
...data.bakery,
|
||||
tenant_id: tenantData.tenant_id,
|
||||
created_at: new Date().toISOString(),
|
||||
isCreating: false
|
||||
}
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
const tenantData = response.data;
|
||||
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: {
|
||||
...data.bakery,
|
||||
tenant_id: tenantData.id,
|
||||
created_at: tenantData.created_at,
|
||||
isCreating: false
|
||||
}
|
||||
});
|
||||
|
||||
// Update the tenant store with the new tenant
|
||||
setCurrentTenant(tenantData);
|
||||
|
||||
} else {
|
||||
throw new Error(response.error || 'Failed to create tenant');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating tenant:', error);
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: { ...data.bakery, isCreating: false }
|
||||
bakery: {
|
||||
...data.bakery,
|
||||
isCreating: false,
|
||||
creationError: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Store, MapPin, Phone, Mail } from 'lucide-react';
|
||||
import { Store, MapPin, Phone, Mail, Hash, Building } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
|
||||
// Backend-compatible bakery setup interface
|
||||
interface BakerySetupData {
|
||||
name: string;
|
||||
business_type: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
||||
business_model: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
||||
address: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
@@ -11,36 +24,39 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
const [formData, setFormData] = useState<BakerySetupData>({
|
||||
name: data.bakery?.name || '',
|
||||
type: data.bakery?.type || '',
|
||||
location: data.bakery?.location || '',
|
||||
business_type: data.bakery?.business_type || 'bakery',
|
||||
business_model: data.bakery?.business_model || 'individual_bakery',
|
||||
address: data.bakery?.address || '',
|
||||
city: data.bakery?.city || 'Madrid',
|
||||
postal_code: data.bakery?.postal_code || '',
|
||||
phone: data.bakery?.phone || '',
|
||||
email: data.bakery?.email || '',
|
||||
description: data.bakery?.description || ''
|
||||
});
|
||||
|
||||
const bakeryTypes = [
|
||||
const bakeryModels = [
|
||||
{
|
||||
value: 'artisan',
|
||||
label: 'Panadería Artesanal',
|
||||
value: 'individual_bakery',
|
||||
label: 'Panadería Individual',
|
||||
description: 'Producción propia tradicional con recetas artesanales',
|
||||
icon: '🥖'
|
||||
},
|
||||
{
|
||||
value: 'industrial',
|
||||
label: 'Panadería Industrial',
|
||||
description: 'Producción a gran escala con procesos automatizados',
|
||||
value: 'central_baker_satellite',
|
||||
label: 'Panadería Central con Satélites',
|
||||
description: 'Producción centralizada con múltiples puntos de venta',
|
||||
icon: '🏭'
|
||||
},
|
||||
{
|
||||
value: 'retail',
|
||||
value: 'retail_bakery',
|
||||
label: 'Panadería Retail',
|
||||
description: 'Punto de venta que compra productos terminados',
|
||||
icon: '🏪'
|
||||
},
|
||||
{
|
||||
value: 'hybrid',
|
||||
value: 'hybrid_bakery',
|
||||
label: 'Modelo Híbrido',
|
||||
description: 'Combina producción propia con productos externos',
|
||||
icon: '🔄'
|
||||
@@ -57,7 +73,7 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
});
|
||||
}, [formData]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
const handleInputChange = (field: keyof BakerySetupData, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
@@ -84,18 +100,18 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bakery Type - Simplified */}
|
||||
{/* Business Model Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-4">
|
||||
Tipo de Panadería *
|
||||
Modelo de Negocio *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{bakeryTypes.map((type) => (
|
||||
{bakeryModels.map((model) => (
|
||||
<label
|
||||
key={type.value}
|
||||
key={model.value}
|
||||
className={`
|
||||
flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 hover:shadow-sm
|
||||
${formData.type === type.value
|
||||
${formData.business_model === model.value
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
|
||||
}
|
||||
@@ -103,20 +119,20 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="bakeryType"
|
||||
value={type.value}
|
||||
checked={formData.type === type.value}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
name="businessModel"
|
||||
value={model.value}
|
||||
checked={formData.business_model === model.value}
|
||||
onChange={(e) => handleInputChange('business_model', e.target.value as BakerySetupData['business_model'])}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex items-center space-x-3 w-full">
|
||||
<span className="text-2xl">{type.icon}</span>
|
||||
<span className="text-2xl">{model.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{type.label}
|
||||
{model.label}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{type.description}
|
||||
{model.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,17 +141,17 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location and Contact - Simplified */}
|
||||
{/* Location and Contact - Backend compatible */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
Ubicación *
|
||||
Dirección *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
value={formData.location}
|
||||
onChange={(e) => handleInputChange('location', e.target.value)}
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange('address', e.target.value)}
|
||||
placeholder="Dirección completa de tu panadería"
|
||||
className="w-full pl-12 py-3"
|
||||
/>
|
||||
@@ -145,14 +161,48 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Teléfono (opcional)
|
||||
Ciudad *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange('city', e.target.value)}
|
||||
placeholder="Madrid"
|
||||
className="w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Código Postal *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Hash className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
value={formData.postal_code}
|
||||
onChange={(e) => handleInputChange('postal_code', e.target.value)}
|
||||
placeholder="28001"
|
||||
pattern="[0-9]{5}"
|
||||
className="w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Teléfono *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="+1 234 567 8900"
|
||||
placeholder="+34 600 123 456"
|
||||
type="tel"
|
||||
className="w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
@@ -177,7 +227,7 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional: Show loading state when creating tenant */}
|
||||
{/* Show loading state when creating tenant */}
|
||||
{data.bakery?.isCreating && (
|
||||
<div className="text-center p-6 bg-[var(--color-primary)]/5 rounded-lg">
|
||||
<div className="animate-spin w-8 h-8 border-3 border-[var(--color-primary)] border-t-transparent rounded-full mx-auto mb-4" />
|
||||
@@ -186,6 +236,18 @@ export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show error state if tenant creation fails */}
|
||||
{data.bakery?.creationError && (
|
||||
<div className="text-center p-6 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-600 font-medium mb-2">
|
||||
Error al crear el espacio de trabajo
|
||||
</p>
|
||||
<p className="text-red-500 text-sm">
|
||||
{data.bakery.creationError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useCallback, forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useTenantInitializer } from '../../../hooks/useTenantInitializer';
|
||||
import { Header } from '../Header';
|
||||
import { Sidebar } from '../Sidebar';
|
||||
import { Footer } from '../Footer';
|
||||
@@ -77,6 +78,9 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
const authLoading = false; // Since we're in a protected route, auth loading should be false
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// Initialize tenant data for authenticated users
|
||||
useTenantInitializer();
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
@@ -3,12 +3,11 @@ import { clsx } from 'clsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useBakery } from '../../../contexts/BakeryContext';
|
||||
import { Button } from '../../ui';
|
||||
import { Avatar } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Modal } from '../../ui';
|
||||
import { BakerySelector } from '../../ui/BakerySelector/BakerySelector';
|
||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
@@ -102,7 +101,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const { logout } = useAuthActions();
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const { bakeries, currentBakery, selectBakery } = useBakery();
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
@@ -250,35 +248,21 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bakery Selector - Desktop */}
|
||||
{isAuthenticated && currentBakery && bakeries.length > 0 && (
|
||||
{/* Tenant Switcher - Desktop */}
|
||||
{isAuthenticated && (
|
||||
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0">
|
||||
<BakerySelector
|
||||
bakeries={bakeries}
|
||||
selectedBakery={currentBakery}
|
||||
onSelectBakery={selectBakery}
|
||||
onAddBakery={() => {
|
||||
// TODO: Navigate to add bakery page or open modal
|
||||
console.log('Add new bakery');
|
||||
}}
|
||||
size="md"
|
||||
<TenantSwitcher
|
||||
showLabel={true}
|
||||
className="min-w-[160px] max-w-[220px] lg:min-w-[200px] lg:max-w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bakery Selector - Mobile (in title area) */}
|
||||
{isAuthenticated && currentBakery && bakeries.length > 0 && (
|
||||
{/* Tenant Switcher - Mobile (in title area) */}
|
||||
{isAuthenticated && (
|
||||
<div className="md:hidden flex-1 min-w-0 ml-3">
|
||||
<BakerySelector
|
||||
bakeries={bakeries}
|
||||
selectedBakery={currentBakery}
|
||||
onSelectBakery={selectBakery}
|
||||
onAddBakery={() => {
|
||||
// TODO: Navigate to add bakery page or open modal
|
||||
console.log('Add new bakery');
|
||||
}}
|
||||
size="sm"
|
||||
<TenantSwitcher
|
||||
showLabel={false}
|
||||
className="w-full max-w-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import { ChevronDown, Building, Check, Plus } from 'lucide-react';
|
||||
import { Button } from '../Button';
|
||||
import { Avatar } from '../Avatar';
|
||||
|
||||
interface Bakery {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
role: 'owner' | 'manager' | 'baker' | 'staff';
|
||||
status: 'active' | 'inactive';
|
||||
address?: string;
|
||||
}
|
||||
|
||||
interface BakerySelectorProps {
|
||||
bakeries: Bakery[];
|
||||
selectedBakery: Bakery;
|
||||
onSelectBakery: (bakery: Bakery) => void;
|
||||
onAddBakery?: () => void;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
owner: 'Propietario',
|
||||
manager: 'Gerente',
|
||||
baker: 'Panadero',
|
||||
staff: 'Personal'
|
||||
};
|
||||
|
||||
const roleColors = {
|
||||
owner: 'text-color-success',
|
||||
manager: 'text-color-info',
|
||||
baker: 'text-color-warning',
|
||||
staff: 'text-text-secondary'
|
||||
};
|
||||
|
||||
export const BakerySelector: React.FC<BakerySelectorProps> = ({
|
||||
bakeries,
|
||||
selectedBakery,
|
||||
onSelectBakery,
|
||||
onAddBakery,
|
||||
className,
|
||||
size = 'md'
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Calculate dropdown position when opening
|
||||
useEffect(() => {
|
||||
if (isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const isMobile = viewportWidth < 640; // sm breakpoint
|
||||
|
||||
let top = rect.bottom + window.scrollY + 8;
|
||||
let left = rect.left + window.scrollX;
|
||||
let width = Math.max(rect.width, isMobile ? viewportWidth - 32 : 320); // 16px margin on each side for mobile
|
||||
|
||||
// Adjust for mobile - center dropdown with margins
|
||||
if (isMobile) {
|
||||
left = 16; // 16px margin from left
|
||||
width = viewportWidth - 32; // 16px margins on both sides
|
||||
} else {
|
||||
// Adjust horizontal position to prevent overflow
|
||||
const dropdownWidth = Math.max(width, 320);
|
||||
if (left + dropdownWidth > viewportWidth - 16) {
|
||||
left = viewportWidth - dropdownWidth - 16;
|
||||
}
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust vertical position if dropdown would overflow bottom
|
||||
const dropdownMaxHeight = 320; // Approximate max height
|
||||
const headerHeight = 64; // Approximate header height
|
||||
|
||||
if (top + dropdownMaxHeight > viewportHeight + window.scrollY - 16) {
|
||||
// Try to position above the button
|
||||
const topPosition = rect.top + window.scrollY - dropdownMaxHeight - 8;
|
||||
|
||||
// Ensure it doesn't go above the header
|
||||
if (topPosition < window.scrollY + headerHeight) {
|
||||
// If it can't fit above, position it at the top of the visible area
|
||||
top = window.scrollY + headerHeight + 8;
|
||||
} else {
|
||||
top = topPosition;
|
||||
}
|
||||
}
|
||||
|
||||
setDropdownPosition({ top, left, width });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (buttonRef.current && !buttonRef.current.contains(event.target as Node) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on escape key and handle body scroll lock
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Prevent body scroll on mobile when dropdown is open
|
||||
const isMobile = window.innerWidth < 640;
|
||||
if (isMobile) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
// Restore body scroll
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-10 px-3 text-sm sm:h-8', // Always at least 40px (10) for better touch targets on mobile
|
||||
md: 'h-12 px-4 text-base sm:h-10', // 48px (12) on mobile, 40px on desktop
|
||||
lg: 'h-14 px-5 text-lg sm:h-12' // 56px (14) on mobile, 48px on desktop
|
||||
};
|
||||
|
||||
const avatarSizes = {
|
||||
sm: 'sm' as const, // Changed from xs to sm for better mobile visibility
|
||||
md: 'sm' as const,
|
||||
lg: 'md' as const
|
||||
};
|
||||
|
||||
const getBakeryInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)} ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 sm:gap-3 bg-[var(--bg-primary)] border border-[var(--border-primary)]',
|
||||
'rounded-lg transition-all duration-200 hover:bg-[var(--bg-secondary)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]',
|
||||
'active:scale-[0.98] w-full',
|
||||
sizeClasses[size],
|
||||
isOpen && 'ring-2 ring-[var(--color-primary)]/20 border-[var(--color-primary)]'
|
||||
)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`Panadería seleccionada: ${selectedBakery.name}`}
|
||||
>
|
||||
<Avatar
|
||||
src={selectedBakery.logo}
|
||||
name={selectedBakery.name}
|
||||
size={avatarSizes[size]}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="text-[var(--text-primary)] font-medium truncate text-sm sm:text-base">
|
||||
{selectedBakery.name}
|
||||
</div>
|
||||
{size !== 'sm' && (
|
||||
<div className={clsx('text-xs truncate hidden sm:block', roleColors[selectedBakery.role])}>
|
||||
{roleLabels[selectedBakery.role]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
'flex-shrink-0 transition-transform duration-200 text-[var(--text-secondary)]',
|
||||
size === 'sm' ? 'w-4 h-4' : 'w-4 h-4', // Consistent sizing
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-[9998] sm:hidden"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-2 z-[9999] sm:min-w-80 sm:max-w-96"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2 text-xs font-medium text-[var(--text-tertiary)] border-b border-[var(--border-primary)]">
|
||||
Mis Panaderías ({bakeries.length})
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 sm:max-h-64 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-[var(--border-secondary)] scrollbar-track-transparent">
|
||||
{bakeries.map((bakery) => (
|
||||
<button
|
||||
key={bakery.id}
|
||||
onClick={() => {
|
||||
onSelectBakery(bakery);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px]',
|
||||
'hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors',
|
||||
'focus:outline-none focus:bg-[var(--bg-secondary)]',
|
||||
'touch-manipulation', // Improves touch responsiveness
|
||||
selectedBakery.id === bakery.id && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
src={bakery.logo}
|
||||
name={bakery.name}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--text-primary)] font-medium truncate">
|
||||
{bakery.name}
|
||||
</span>
|
||||
{selectedBakery.id === bakery.id && (
|
||||
<Check className="w-4 h-4 text-[var(--color-primary)] flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={clsx('text-xs', roleColors[bakery.role])}>
|
||||
{roleLabels[bakery.role]}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">•</span>
|
||||
<span className={clsx(
|
||||
'text-xs',
|
||||
bakery.status === 'active' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
||||
)}>
|
||||
{bakery.status === 'active' ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bakery.address && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate mt-1">
|
||||
{bakery.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onAddBakery && (
|
||||
<>
|
||||
<div className="border-t border-[var(--border-primary)] my-2"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAddBakery();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px] hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-primary)] touch-manipulation"
|
||||
>
|
||||
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="font-medium">Agregar Panadería</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BakerySelector;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BakerySelector } from './BakerySelector';
|
||||
export type { default as BakerySelector } from './BakerySelector';
|
||||
131
frontend/src/components/ui/PasswordCriteria.tsx
Normal file
131
frontend/src/components/ui/PasswordCriteria.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PasswordCriteria {
|
||||
label: string;
|
||||
isValid: boolean;
|
||||
regex?: RegExp;
|
||||
checkFn?: (password: string) => boolean;
|
||||
}
|
||||
|
||||
interface PasswordCriteriaProps {
|
||||
password: string;
|
||||
className?: string;
|
||||
showOnlyFailed?: boolean;
|
||||
}
|
||||
|
||||
export const PasswordCriteria: React.FC<PasswordCriteriaProps> = ({
|
||||
password,
|
||||
className = '',
|
||||
showOnlyFailed = false
|
||||
}) => {
|
||||
const criteria: PasswordCriteria[] = [
|
||||
{
|
||||
label: 'Al menos 8 caracteres',
|
||||
isValid: password.length >= 8,
|
||||
checkFn: (pwd) => pwd.length >= 8
|
||||
},
|
||||
{
|
||||
label: 'Máximo 128 caracteres',
|
||||
isValid: password.length <= 128,
|
||||
checkFn: (pwd) => pwd.length <= 128
|
||||
},
|
||||
{
|
||||
label: 'Al menos una letra mayúscula',
|
||||
isValid: /[A-Z]/.test(password),
|
||||
regex: /[A-Z]/
|
||||
},
|
||||
{
|
||||
label: 'Al menos una letra minúscula',
|
||||
isValid: /[a-z]/.test(password),
|
||||
regex: /[a-z]/
|
||||
},
|
||||
{
|
||||
label: 'Al menos un número',
|
||||
isValid: /\d/.test(password),
|
||||
regex: /\d/
|
||||
}
|
||||
];
|
||||
|
||||
const validatedCriteria = criteria.map(criterion => ({
|
||||
...criterion,
|
||||
isValid: criterion.regex
|
||||
? criterion.regex.test(password)
|
||||
: criterion.checkFn
|
||||
? criterion.checkFn(password)
|
||||
: false
|
||||
}));
|
||||
|
||||
const displayCriteria = showOnlyFailed
|
||||
? validatedCriteria.filter(c => !c.isValid)
|
||||
: validatedCriteria;
|
||||
|
||||
if (displayCriteria.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`text-sm space-y-1 ${className}`}>
|
||||
<p className="text-text-secondary font-medium mb-2">
|
||||
Requisitos de contraseña:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{displayCriteria.map((criterion, index) => (
|
||||
<li key={index} className="flex items-center space-x-2">
|
||||
<span
|
||||
className={`w-4 h-4 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
criterion.isValid
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{criterion.isValid ? '✓' : '✗'}
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
criterion.isValid
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{criterion.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const validatePassword = (password: string): boolean => {
|
||||
return (
|
||||
password.length >= 8 &&
|
||||
password.length <= 128 &&
|
||||
/[A-Z]/.test(password) &&
|
||||
/[a-z]/.test(password) &&
|
||||
/\d/.test(password)
|
||||
);
|
||||
};
|
||||
|
||||
export const getPasswordErrors = (password: string): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('La contraseña debe tener al menos 8 caracteres');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
errors.push('La contraseña no puede exceder 128 caracteres');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos una letra mayúscula');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos una letra minúscula');
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos un número');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
215
frontend/src/components/ui/TenantSwitcher.tsx
Normal file
215
frontend/src/components/ui/TenantSwitcher.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
import { useToast } from '../../hooks/ui/useToast';
|
||||
import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface TenantSwitcherProps {
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
className = '',
|
||||
showLabel = true,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const {
|
||||
currentTenant,
|
||||
availableTenants,
|
||||
isLoading,
|
||||
error,
|
||||
switchTenant,
|
||||
loadUserTenants,
|
||||
clearError,
|
||||
} = useTenant();
|
||||
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
// Load tenants on mount
|
||||
useEffect(() => {
|
||||
if (!availableTenants) {
|
||||
loadUserTenants();
|
||||
}
|
||||
}, [availableTenants, loadUserTenants]);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
!buttonRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle tenant switch
|
||||
const handleTenantSwitch = async (tenantId: string) => {
|
||||
if (tenantId === currentTenant?.id) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await switchTenant(tenantId);
|
||||
setIsOpen(false);
|
||||
|
||||
if (success) {
|
||||
const newTenant = availableTenants?.find(t => t.id === tenantId);
|
||||
showSuccessToast(`Switched to ${newTenant?.name}`, {
|
||||
title: 'Tenant Switched'
|
||||
});
|
||||
} else {
|
||||
showErrorToast(error || 'Failed to switch tenant', {
|
||||
title: 'Switch Failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle retry loading tenants
|
||||
const handleRetry = () => {
|
||||
clearError();
|
||||
loadUserTenants();
|
||||
};
|
||||
|
||||
// Don't render if no tenants available
|
||||
if (!availableTenants || availableTenants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render if only one tenant
|
||||
if (availableTenants.length === 1) {
|
||||
return showLabel ? (
|
||||
<div className={`flex items-center space-x-2 text-text-secondary ${className}`}>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{currentTenant?.name}</span>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-text-primary bg-bg-secondary hover:bg-bg-tertiary border border-border-secondary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Switch tenant"
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-text-secondary" />
|
||||
{showLabel && (
|
||||
<span className="hidden sm:block max-w-32 truncate">
|
||||
{currentTenant?.name || 'Select Tenant'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-text-secondary transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 mt-2 w-72 bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-50"
|
||||
role="listbox"
|
||||
aria-label="Available tenants"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border-primary">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Switch Organization</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Select the organization you want to work with
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 border-b border-border-primary">
|
||||
<div className="flex items-center space-x-2 text-color-error text-xs">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="ml-auto text-color-primary hover:text-color-primary-dark underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tenant List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{availableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
onClick={() => handleTenantSwitch(tenant.id)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-3 text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
role="option"
|
||||
aria-selected={tenant.id === currentTenant?.id}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-color-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Building2 className="w-4 h-4 text-color-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{tenant.name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary truncate">
|
||||
{tenant.business_type} • {tenant.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tenant.id === currentTenant?.id && (
|
||||
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-2 border-t border-border-primary bg-bg-secondary rounded-b-lg">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Need to add a new organization?{' '}
|
||||
<button className="text-color-primary hover:text-color-primary-dark underline">
|
||||
Contact Support
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-bg-primary/50 rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-2 border-color-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export { ListItem } from './ListItem';
|
||||
export { StatsCard, StatsGrid } from './Stats';
|
||||
export { StatusCard, getStatusColor } from './StatusCard';
|
||||
export { StatusModal } from './StatusModal';
|
||||
export { BakerySelector } from './BakerySelector';
|
||||
export { TenantSwitcher } from './TenantSwitcher';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
|
||||
Reference in New Issue
Block a user