Start integrating the onboarding flow with backend 1

This commit is contained in:
Urtzi Alfaro
2025-09-03 18:29:56 +02:00
parent a55d48e635
commit a11fdfba24
31 changed files with 1202 additions and 1142 deletions

View File

@@ -8,7 +8,6 @@ import { LoadingSpinner } from './components/shared/LoadingSpinner';
import { AppRouter } from './router/AppRouter';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { BakeryProvider } from './contexts/BakeryContext';
import { SSEProvider } from './contexts/SSEContext';
const queryClient = new QueryClient({
@@ -29,7 +28,6 @@ function App() {
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<BakeryProvider>
<SSEProvider>
<Suspense fallback={<LoadingSpinner overlay />}>
<AppRouter />
@@ -45,7 +43,6 @@ function App() {
/>
</Suspense>
</SSEProvider>
</BakeryProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>

View File

@@ -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);

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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!');

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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>

View File

@@ -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;

View File

@@ -1,2 +0,0 @@
export { BakerySelector } from './BakerySelector';
export type { default as BakerySelector } from './BakerySelector';

View 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;
};

View 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>
);
};

View File

@@ -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';

View File

@@ -8,9 +8,9 @@ export const MOCK_CONFIG = {
MOCK_MODE: true,
// Component-specific toggles
MOCK_REGISTRATION: true,
MOCK_AUTHENTICATION: true,
MOCK_ONBOARDING_FLOW: true,
MOCK_REGISTRATION: false, // Now using real backend
MOCK_AUTHENTICATION: false, // Now using real backend
MOCK_ONBOARDING_FLOW: true, // Keep onboarding mock for now
// Mock user data
MOCK_USER: {

View File

@@ -1,359 +0,0 @@
import React, { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
export interface Bakery {
id: string;
name: string;
logo?: string;
role: 'owner' | 'manager' | 'baker' | 'staff';
status: 'active' | 'inactive';
address?: string;
description?: string;
settings?: {
timezone: string;
currency: string;
language: string;
};
permissions?: string[];
}
interface BakeryState {
bakeries: Bakery[];
currentBakery: Bakery | null;
isLoading: boolean;
error: string | null;
}
type BakeryAction =
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_BAKERIES'; payload: Bakery[] }
| { type: 'SET_CURRENT_BAKERY'; payload: Bakery }
| { type: 'ADD_BAKERY'; payload: Bakery }
| { type: 'UPDATE_BAKERY'; payload: { id: string; updates: Partial<Bakery> } }
| { type: 'REMOVE_BAKERY'; payload: string };
const initialState: BakeryState = {
bakeries: [],
currentBakery: null,
isLoading: false,
error: null,
};
// Mock bakeries data
const mockBakeries: Bakery[] = [
{
id: '1',
name: 'Panadería San Miguel',
logo: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=100&h=100&fit=crop&crop=center',
role: 'owner',
status: 'active',
address: 'Calle Mayor 123, Madrid, 28013',
description: 'Panadería tradicional familiar con más de 30 años de experiencia.',
settings: {
timezone: 'Europe/Madrid',
currency: 'EUR',
language: 'es',
},
permissions: ['*'],
},
{
id: '2',
name: 'Panadería La Artesana',
logo: 'https://images.unsplash.com/photo-1509440159596-0249088772ff?w=100&h=100&fit=crop&crop=center',
role: 'manager',
status: 'active',
address: 'Avenida de la Paz 45, Barcelona, 08001',
description: 'Panadería artesanal especializada en panes tradicionales catalanes.',
settings: {
timezone: 'Europe/Madrid',
currency: 'EUR',
language: 'ca',
},
permissions: ['inventory:*', 'production:*', 'sales:*', 'reports:read'],
},
{
id: '3',
name: 'Pan y Masa',
logo: 'https://images.unsplash.com/photo-1524704654690-b56c05c78a00?w=100&h=100&fit=crop&crop=center',
role: 'baker',
status: 'active',
address: 'Plaza Central 8, Valencia, 46001',
description: 'Panadería moderna con enfoque en productos orgánicos.',
settings: {
timezone: 'Europe/Madrid',
currency: 'EUR',
language: 'es',
},
permissions: ['production:*', 'inventory:read', 'inventory:update'],
},
{
id: '4',
name: 'Horno Dorado',
role: 'staff',
status: 'inactive',
address: 'Calle del Sol 67, Sevilla, 41001',
description: 'Panadería tradicional andaluza.',
settings: {
timezone: 'Europe/Madrid',
currency: 'EUR',
language: 'es',
},
permissions: ['inventory:read', 'sales:read'],
},
];
function bakeryReducer(state: BakeryState, action: BakeryAction): BakeryState {
switch (action.type) {
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, isLoading: false };
case 'SET_BAKERIES':
return {
...state,
bakeries: action.payload,
currentBakery: state.currentBakery || action.payload[0] || null,
isLoading: false,
error: null
};
case 'SET_CURRENT_BAKERY':
// Save to localStorage for persistence
localStorage.setItem('selectedBakery', action.payload.id);
return { ...state, currentBakery: action.payload };
case 'ADD_BAKERY':
return {
...state,
bakeries: [...state.bakeries, action.payload]
};
case 'UPDATE_BAKERY':
return {
...state,
bakeries: state.bakeries.map(bakery =>
bakery.id === action.payload.id
? { ...bakery, ...action.payload.updates }
: bakery
),
currentBakery: state.currentBakery?.id === action.payload.id
? { ...state.currentBakery, ...action.payload.updates }
: state.currentBakery
};
case 'REMOVE_BAKERY':
const remainingBakeries = state.bakeries.filter(b => b.id !== action.payload);
return {
...state,
bakeries: remainingBakeries,
currentBakery: state.currentBakery?.id === action.payload
? remainingBakeries[0] || null
: state.currentBakery
};
default:
return state;
}
}
interface BakeryContextType extends BakeryState {
selectBakery: (bakery: Bakery) => void;
addBakery: (bakery: Omit<Bakery, 'id'>) => Promise<void>;
updateBakery: (id: string, updates: Partial<Bakery>) => Promise<void>;
removeBakery: (id: string) => Promise<void>;
refreshBakeries: () => Promise<void>;
hasPermission: (permission: string) => boolean;
canAccess: (resource: string, action: string) => boolean;
}
const BakeryContext = createContext<BakeryContextType | undefined>(undefined);
interface BakeryProviderProps {
children: ReactNode;
}
export function BakeryProvider({ children }: BakeryProviderProps) {
const [state, dispatch] = useReducer(bakeryReducer, initialState);
// Load bakeries on mount
useEffect(() => {
loadBakeries();
}, []);
// Load saved bakery selection
useEffect(() => {
if (state.bakeries.length > 0 && !state.currentBakery) {
const savedBakeryId = localStorage.getItem('selectedBakery');
const savedBakery = savedBakeryId
? state.bakeries.find(b => b.id === savedBakeryId)
: null;
if (savedBakery) {
dispatch({ type: 'SET_CURRENT_BAKERY', payload: savedBakery });
} else if (state.bakeries[0]) {
dispatch({ type: 'SET_CURRENT_BAKERY', payload: state.bakeries[0] });
}
}
}, [state.bakeries, state.currentBakery]);
const loadBakeries = async () => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 1000));
// In a real app, this would be an API call
dispatch({ type: 'SET_BAKERIES', payload: mockBakeries });
} catch (error) {
dispatch({
type: 'SET_ERROR',
payload: error instanceof Error ? error.message : 'Error loading bakeries'
});
}
};
const selectBakery = (bakery: Bakery) => {
dispatch({ type: 'SET_CURRENT_BAKERY', payload: bakery });
};
const addBakery = async (bakeryData: Omit<Bakery, 'id'>) => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
const newBakery: Bakery = {
...bakeryData,
id: Date.now().toString(), // In real app, this would come from the API
};
dispatch({ type: 'ADD_BAKERY', payload: newBakery });
dispatch({ type: 'SET_LOADING', payload: false });
} catch (error) {
dispatch({
type: 'SET_ERROR',
payload: error instanceof Error ? error.message : 'Error adding bakery'
});
}
};
const updateBakery = async (id: string, updates: Partial<Bakery>) => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
dispatch({ type: 'UPDATE_BAKERY', payload: { id, updates } });
dispatch({ type: 'SET_LOADING', payload: false });
} catch (error) {
dispatch({
type: 'SET_ERROR',
payload: error instanceof Error ? error.message : 'Error updating bakery'
});
}
};
const removeBakery = async (id: string) => {
try {
dispatch({ type: 'SET_LOADING', payload: true });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
dispatch({ type: 'REMOVE_BAKERY', payload: id });
dispatch({ type: 'SET_LOADING', payload: false });
} catch (error) {
dispatch({
type: 'SET_ERROR',
payload: error instanceof Error ? error.message : 'Error removing bakery'
});
}
};
const refreshBakeries = async () => {
await loadBakeries();
};
const hasPermission = (permission: string): boolean => {
if (!state.currentBakery) return false;
const permissions = state.currentBakery.permissions || [];
// Admin/owner has all permissions
if (permissions.includes('*')) return true;
return permissions.includes(permission);
};
const canAccess = (resource: string, action: string): boolean => {
if (!state.currentBakery) return false;
// Check specific permission
if (hasPermission(`${resource}:${action}`)) return true;
// Check wildcard permissions
if (hasPermission(`${resource}:*`)) return true;
// Role-based access fallback
switch (state.currentBakery.role) {
case 'owner':
return true;
case 'manager':
return ['inventory', 'production', 'sales', 'reports'].includes(resource);
case 'baker':
return ['production', 'inventory'].includes(resource) &&
['read', 'update'].includes(action);
case 'staff':
return ['inventory', 'sales'].includes(resource) &&
action === 'read';
default:
return false;
}
};
const value: BakeryContextType = {
...state,
selectBakery,
addBakery,
updateBakery,
removeBakery,
refreshBakeries,
hasPermission,
canAccess,
};
return (
<BakeryContext.Provider value={value}>
{children}
</BakeryContext.Provider>
);
}
export function useBakery(): BakeryContextType {
const context = useContext(BakeryContext);
if (context === undefined) {
throw new Error('useBakery must be used within a BakeryProvider');
}
return context;
}
// Convenience hooks
export const useCurrentBakery = () => {
const { currentBakery } = useBakery();
return currentBakery;
};
export const useBakeries = () => {
const { bakeries } = useBakery();
return bakeries;
};
export const useBakeryPermissions = () => {
const { hasPermission, canAccess } = useBakery();
return { hasPermission, canAccess };
};

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useIsAuthenticated } from '../stores/auth.store';
import { useTenantActions, useAvailableTenants } from '../stores/tenant.store';
/**
* Hook to automatically initialize tenant data when user is authenticated
* This should be used at the app level to ensure tenant data is loaded
*/
export const useTenantInitializer = () => {
const isAuthenticated = useIsAuthenticated();
const availableTenants = useAvailableTenants();
const { loadUserTenants } = useTenantActions();
useEffect(() => {
if (isAuthenticated && !availableTenants) {
// Load user's available tenants when authenticated and not already loaded
loadUserTenants();
}
}, [isAuthenticated, availableTenants, loadUserTenants]);
// Also load tenants when user becomes authenticated (e.g., after login)
useEffect(() => {
if (isAuthenticated && availableTenants === null) {
loadUserTenants();
}
}, [isAuthenticated, availableTenants, loadUserTenants]);
};

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
import { onboardingApiService } from '../../../services/api/onboarding.service';
import { useAuth } from '../../../hooks/useAuth';
import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store';
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
// Step Components
@@ -16,7 +16,8 @@ import { CompletionStep } from '../../../components/domain/onboarding/steps/Comp
const OnboardingPage: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const user = useAuthUser();
const isAuthenticated = useIsAuthenticated();
const [isLoading, setIsLoading] = useState(false);
const [globalData, setGlobalData] = useState<any>({});
@@ -30,8 +31,11 @@ const OnboardingPage: React.FC = () => {
isRequired: true,
validation: (data) => {
if (!data.bakery?.name) return 'El nombre de la panadería es requerido';
if (!data.bakery?.type) return 'El tipo de panadería es requerido';
if (!data.bakery?.location) return 'La ubicación es requerida';
if (!data.bakery?.business_model) return 'El modelo de negocio es requerido';
if (!data.bakery?.address) return 'La dirección es requerida';
if (!data.bakery?.city) return 'La ciudad es requerida';
if (!data.bakery?.postal_code) return 'El código postal es requerido';
if (!data.bakery?.phone) return 'El teléfono es requerido';
// Tenant creation will happen automatically when validation passes
return null;
}
@@ -142,10 +146,27 @@ const OnboardingPage: React.FC = () => {
}
};
// Redirect to login if not authenticated
useEffect(() => {
if (!isAuthenticated) {
navigate('/login', {
state: {
message: 'Debes iniciar sesión para acceder al onboarding.',
returnUrl: '/app/onboarding'
}
});
}
}, [isAuthenticated, navigate]);
if (isLoading) {
return <LoadingSpinner overlay text="Completando configuración..." />;
}
// Don't render if not authenticated (will redirect)
if (!isAuthenticated || !user) {
return <LoadingSpinner overlay text="Verificando autenticación..." />;
}
return (
<div className="min-h-screen bg-[var(--bg-primary)]">
<OnboardingWizard

View File

@@ -30,8 +30,8 @@ import {
Download,
ExternalLink
} from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth';
import { useBakeryStore } from '../../../../stores/bakery.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import {
subscriptionService,
@@ -238,8 +238,8 @@ const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onU
};
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore();
const user = useAuthUser();
const currentTenant = useCurrentTenant();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
@@ -249,13 +249,13 @@ const SubscriptionPage: React.FC = () => {
const [upgrading, setUpgrading] = useState(false);
useEffect(() => {
if (currentTenant?.id || tenant_id || isMockMode()) {
if (currentTenant?.id || user?.tenant_id || isMockMode()) {
loadSubscriptionData();
}
}, [currentTenant, tenant_id]);
}, [currentTenant, user?.tenant_id]);
const loadSubscriptionData = async () => {
let tenantId = currentTenant?.id || tenant_id;
let tenantId = currentTenant?.id || user?.tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {
@@ -290,7 +290,7 @@ const SubscriptionPage: React.FC = () => {
};
const handleUpgradeConfirm = async () => {
let tenantId = currentTenant?.id || tenant_id;
let tenantId = currentTenant?.id || user?.tenant_id;
// In mock mode, use the mock tenant ID if no real tenant is available
if (isMockMode() && !tenantId) {

View File

@@ -31,7 +31,7 @@ import {
ExternalLink
} from 'lucide-react';
import { useAuth } from '../../../../hooks/api/useAuth';
import { useBakeryStore } from '../../../../stores/bakery.store';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import {
subscriptionService,
@@ -287,7 +287,7 @@ const PlanComparison: React.FC<PlanComparisonProps> = ({ plans, currentPlan, onU
const SubscriptionPage: React.FC = () => {
const { user, tenant_id } = useAuth();
const { currentTenant } = useBakeryStore();
const currentTenant = useCurrentTenant();
const toast = useToast();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);

View File

@@ -6,7 +6,6 @@ import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
import { isMockAuthentication } from '../config/mock.config';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -129,12 +128,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
const isLoading = useAuthLoading();
const location = useLocation();
// MOCK MODE - Allow access to onboarding routes for testing
const isOnboardingRoute = location.pathname.startsWith('/app/onboarding');
if (isMockAuthentication() && isOnboardingRoute) {
return <>{children}</>;
}
// Note: Onboarding routes are now properly protected and require authentication
// Mock mode only applies to the onboarding flow content, not to route protection
// Show loading spinner while checking authentication
if (isLoading) {

View File

@@ -86,7 +86,13 @@ class AuthService {
// Authentication endpoints
async register(userData: UserRegistration): Promise<ApiResponse<TokenResponse>> {
return apiClient.post(`${this.baseUrl}/register`, userData);
const response = await apiClient.post<TokenResponse>(`${this.baseUrl}/register`, userData);
if (response.success && response.data) {
this.handleSuccessfulAuth(response.data);
}
return response;
}
async login(credentials: UserLogin): Promise<ApiResponse<TokenResponse>> {
@@ -164,54 +170,70 @@ class AuthService {
return apiClient.post(`${this.baseUrl}/verify-email/confirm`, { token });
}
// Local auth state management
// Local auth state management - Now handled by Zustand store
private handleSuccessfulAuth(tokenData: TokenResponse) {
localStorage.setItem('access_token', tokenData.access_token);
if (tokenData.refresh_token) {
localStorage.setItem('refresh_token', tokenData.refresh_token);
}
if (tokenData.user) {
localStorage.setItem('user_data', JSON.stringify(tokenData.user));
if (tokenData.user.tenant_id) {
localStorage.setItem('tenant_id', tokenData.user.tenant_id);
apiClient.setTenantId(tokenData.user.tenant_id);
}
}
// Set auth token for API client
apiClient.setAuthToken(tokenData.access_token);
// Set tenant ID for API client if available
if (tokenData.user?.tenant_id) {
apiClient.setTenantId(tokenData.user.tenant_id);
}
}
private clearAuthData() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
localStorage.removeItem('tenant_id');
// Clear API client tokens
apiClient.removeAuthToken();
}
// Utility methods
// Utility methods - Now get data from Zustand store
isAuthenticated(): boolean {
return !!localStorage.getItem('access_token');
const authStorage = localStorage.getItem('auth-storage');
if (!authStorage) return false;
try {
const { state } = JSON.parse(authStorage);
return state?.isAuthenticated || false;
} catch {
return false;
}
}
getCurrentUserData(): UserData | null {
const userData = localStorage.getItem('user_data');
return userData ? JSON.parse(userData) : null;
const authStorage = localStorage.getItem('auth-storage');
if (!authStorage) return null;
try {
const { state } = JSON.parse(authStorage);
return state?.user || null;
} catch {
return null;
}
}
getAccessToken(): string | null {
return localStorage.getItem('access_token');
const authStorage = localStorage.getItem('auth-storage');
if (!authStorage) return null;
try {
const { state } = JSON.parse(authStorage);
return state?.token || null;
} catch {
return null;
}
}
getRefreshToken(): string | null {
return localStorage.getItem('refresh_token');
const authStorage = localStorage.getItem('auth-storage');
if (!authStorage) return null;
try {
const { state } = JSON.parse(authStorage);
return state?.refreshToken || null;
} catch {
return null;
}
}
getTenantId(): string | null {
return localStorage.getItem('tenant_id');
const userData = this.getCurrentUserData();
return userData?.tenant_id || null;
}
// Check if token is expired (basic check)

View File

@@ -1,5 +1,32 @@
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
// Utility functions to access auth and tenant store data from localStorage
const getAuthData = () => {
const authStorage = localStorage.getItem('auth-storage');
if (!authStorage) return null;
try {
const { state } = JSON.parse(authStorage);
return state;
} catch {
return null;
}
};
const getTenantData = () => {
const tenantStorage = localStorage.getItem('tenant-storage');
if (!tenantStorage) return null;
try {
const { state } = JSON.parse(tenantStorage);
return state;
} catch {
return null;
}
};
const clearAuthData = () => {
localStorage.removeItem('auth-storage');
};
export interface ApiResponse<T = any> {
data: T;
success: boolean;
@@ -41,12 +68,15 @@ class ApiClient {
// Request interceptor - add auth token and tenant ID
this.axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
const authData = getAuthData();
if (authData?.token) {
config.headers.Authorization = `Bearer ${authData.token}`;
}
const tenantId = localStorage.getItem('tenant_id');
// Get tenant ID from tenant store (priority) or fallback to user's tenant_id
const tenantData = getTenantData();
const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id;
if (tenantId) {
config.headers['X-Tenant-ID'] = tenantId;
}
@@ -67,8 +97,8 @@ class ApiClient {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
const authData = getAuthData();
if (authData?.refreshToken) {
const newToken = await this.refreshToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.axiosInstance(originalRequest);
@@ -113,29 +143,34 @@ class ApiClient {
}
private async performTokenRefresh(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
const authData = getAuthData();
if (!authData?.refreshToken) {
throw new Error('No refresh token available');
}
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refresh_token: refreshToken,
refresh_token: authData.refreshToken,
});
const { access_token, refresh_token } = response.data;
localStorage.setItem('access_token', access_token);
if (refresh_token) {
localStorage.setItem('refresh_token', refresh_token);
}
// Update the Zustand store by modifying the auth-storage directly
const newAuthData = {
...authData,
token: access_token,
refreshToken: refresh_token || authData.refreshToken
};
localStorage.setItem('auth-storage', JSON.stringify({
state: newAuthData,
version: 0
}));
return access_token;
}
private handleAuthFailure() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
localStorage.removeItem('tenant_id');
clearAuthData();
// Redirect to login
window.location.href = '/login';
@@ -197,19 +232,16 @@ class ApiClient {
};
}
// Utility methods
// Utility methods - Now work with Zustand store
setAuthToken(token: string) {
localStorage.setItem('access_token', token);
this.axiosInstance.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
removeAuthToken() {
localStorage.removeItem('access_token');
delete this.axiosInstance.defaults.headers.common['Authorization'];
}
setTenantId(tenantId: string) {
localStorage.setItem('tenant_id', tenantId);
this.axiosInstance.defaults.headers.common['X-Tenant-ID'] = tenantId;
}

View File

@@ -1,6 +1,6 @@
import { apiClient, ApiResponse } from './client';
// Request/Response Types based on backend schemas
// Request/Response Types based on backend schemas - UPDATED TO MATCH BACKEND
export interface BakeryRegistration {
name: string;
address: string;
@@ -101,7 +101,7 @@ class TenantService {
// Tenant CRUD operations
async createTenant(tenantData: BakeryRegistration): Promise<ApiResponse<TenantResponse>> {
return apiClient.post(`${this.baseUrl}`, tenantData);
return apiClient.post(`${this.baseUrl}/register`, tenantData);
}
async getTenant(tenantId: string): Promise<ApiResponse<TenantResponse>> {
@@ -151,15 +151,31 @@ class TenantService {
}
async switchTenant(tenantId: string): Promise<ApiResponse<{ message: string; tenant: TenantResponse }>> {
const response = await apiClient.post(`${this.baseUrl}/${tenantId}/switch`);
if (response.success && response.data?.tenant) {
// Update local tenant context
localStorage.setItem('tenant_id', tenantId);
apiClient.setTenantId(tenantId);
// Frontend-only tenant switching since backend doesn't have this endpoint
// We'll simulate the response and update the tenant store directly
try {
const tenant = await this.getTenant(tenantId);
if (tenant.success) {
// Update API client tenant context
apiClient.setTenantId(tenantId);
return {
success: true,
data: {
message: 'Tenant switched successfully',
tenant: tenant.data
}
};
} else {
throw new Error('Tenant not found');
}
} catch (error) {
return {
success: false,
data: null,
error: error instanceof Error ? error.message : 'Failed to switch tenant'
};
}
return response;
}
// Member management
@@ -228,33 +244,33 @@ class TenantService {
}
// Utility methods
async getUserTenants(): Promise<ApiResponse<TenantResponse[]>> {
return apiClient.get(`${this.baseUrl}/my-tenants`);
async getUserTenants(userId?: string): Promise<ApiResponse<TenantResponse[]>> {
// If no userId provided, we'll get it from the auth store/token
return apiClient.get(`${this.baseUrl}/users/${userId || 'current'}`);
}
async validateTenantSlug(slug: string): Promise<ApiResponse<{ available: boolean; suggestions?: string[] }>> {
return apiClient.get(`${this.baseUrl}/validate-slug/${slug}`);
}
// Local state management helpers
// Local state management helpers - Now uses tenant store
getCurrentTenantId(): string | null {
return localStorage.getItem('tenant_id');
// This will be handled by the tenant store
return null;
}
getCurrentTenantData(): TenantResponse | null {
const tenantData = localStorage.getItem('tenant_data');
return tenantData ? JSON.parse(tenantData) : null;
// This will be handled by the tenant store
return null;
}
setCurrentTenant(tenant: TenantResponse) {
localStorage.setItem('tenant_id', tenant.id);
localStorage.setItem('tenant_data', JSON.stringify(tenant));
// This will be handled by the tenant store
apiClient.setTenantId(tenant.id);
}
clearCurrentTenant() {
localStorage.removeItem('tenant_id');
localStorage.removeItem('tenant_data');
// This will be handled by the tenant store
}
// Business type helpers

View File

@@ -4,19 +4,16 @@ import { persist, createJSONStorage } from 'zustand/middleware';
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'manager' | 'baker' | 'staff';
permissions: string[];
tenantId: string;
tenantName: string;
avatar?: string;
lastLogin?: string;
preferences?: {
language: string;
timezone: string;
theme: 'light' | 'dark';
notifications: boolean;
};
full_name: string; // Updated to match backend
is_active: boolean;
is_verified: boolean;
created_at: string;
last_login?: string;
phone?: string;
language?: string;
timezone?: string;
tenant_id?: string;
role?: string;
}
export interface AuthState {
@@ -30,6 +27,7 @@ export interface AuthState {
// Actions
login: (email: string, password: string) => Promise<void>;
register: (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => Promise<void>;
logout: () => void;
refreshAuth: () => Promise<void>;
updateUser: (updates: Partial<User>) => void;
@@ -42,50 +40,7 @@ export interface AuthState {
canAccess: (resource: string, action: string) => boolean;
}
// Mock API functions (replace with actual API calls)
const mockLogin = async (email: string, password: string): Promise<{ user: User; token: string; refreshToken: string }> => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
if (email === 'admin@bakery.com' && password === 'admin12345') {
return {
user: {
id: '1',
email: 'admin@bakery.com',
name: 'Admin User',
role: 'admin',
permissions: ['*'],
tenantId: 'tenant-1',
tenantName: 'Panadería San Miguel',
avatar: undefined,
lastLogin: new Date().toISOString(),
preferences: {
language: 'es',
timezone: 'Europe/Madrid',
theme: 'light',
notifications: true,
},
},
token: 'mock-jwt-token',
refreshToken: 'mock-refresh-token',
};
}
throw new Error('Credenciales inválidas');
};
const mockRefreshToken = async (refreshToken: string): Promise<{ token: string; refreshToken: string }> => {
await new Promise(resolve => setTimeout(resolve, 500));
if (refreshToken === 'mock-refresh-token') {
return {
token: 'new-mock-jwt-token',
refreshToken: 'new-mock-refresh-token',
};
}
throw new Error('Invalid refresh token');
};
import { authService } from '../services/api/auth.service';
export const useAuthStore = create<AuthState>()(
persist(
@@ -103,16 +58,20 @@ export const useAuthStore = create<AuthState>()(
try {
set({ isLoading: true, error: null });
const response = await mockLogin(email, password);
const response = await authService.login({ email, password });
set({
user: response.user,
token: response.token,
refreshToken: response.refreshToken,
isAuthenticated: true,
isLoading: false,
error: null,
});
if (response.success && response.data) {
set({
user: response.data.user || null,
token: response.data.access_token,
refreshToken: response.data.refresh_token || null,
isAuthenticated: true,
isLoading: false,
error: null,
});
} else {
throw new Error('Login failed');
}
} catch (error) {
set({
user: null,
@@ -126,6 +85,37 @@ export const useAuthStore = create<AuthState>()(
}
},
register: async (userData: { email: string; password: string; full_name: string; tenant_name?: string }) => {
try {
set({ isLoading: true, error: null });
const response = await authService.register(userData);
if (response.success && response.data) {
set({
user: response.data.user || null,
token: response.data.access_token,
refreshToken: response.data.refresh_token || null,
isAuthenticated: true,
isLoading: false,
error: null,
});
} else {
throw new Error('Registration failed');
}
} catch (error) {
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'Error de registro',
});
throw error;
}
},
logout: () => {
set({
user: null,
@@ -146,14 +136,18 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: true });
const response = await mockRefreshToken(refreshToken);
const response = await authService.refreshToken(refreshToken);
set({
token: response.token,
refreshToken: response.refreshToken,
isLoading: false,
error: null,
});
if (response.success && response.data) {
set({
token: response.data.access_token,
refreshToken: response.data.refresh_token || refreshToken,
isLoading: false,
error: null,
});
} else {
throw new Error('Token refresh failed');
}
} catch (error) {
set({
user: null,
@@ -184,15 +178,16 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: loading });
},
// Permission helpers
hasPermission: (permission: string): boolean => {
// Permission helpers - Simplified for backend compatibility
hasPermission: (_permission: string): boolean => {
const { user } = get();
if (!user) return false;
if (!user || !user.is_active) return false;
// Admin has all permissions
if (user.permissions.includes('*')) return true;
if (user.role === 'admin') return true;
return user.permissions.includes(permission);
// Basic role-based permissions
return false;
},
hasRole: (role: string): boolean => {
@@ -201,28 +196,17 @@ export const useAuthStore = create<AuthState>()(
},
canAccess: (resource: string, action: string): boolean => {
const { user, hasPermission } = get();
if (!user) return false;
const { user } = get();
if (!user || !user.is_active) return false;
// Check specific permission
if (hasPermission(`${resource}:${action}`)) return true;
// Check wildcard permissions
if (hasPermission(`${resource}:*`)) return true;
if (hasPermission('*')) return true;
// Role-based access fallback
// Role-based access control
switch (user.role) {
case 'admin':
return true;
case 'manager':
return ['inventory', 'production', 'sales', 'reports'].includes(resource);
case 'baker':
return ['production', 'inventory'].includes(resource) &&
['read', 'update'].includes(action);
case 'staff':
return ['inventory', 'sales'].includes(resource) &&
action === 'read';
case 'user':
return ['inventory', 'sales'].includes(resource) && action === 'read';
default:
return false;
}
@@ -237,6 +221,18 @@ export const useAuthStore = create<AuthState>()(
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
onRehydrateStorage: () => (state) => {
// Initialize API client with stored token when store rehydrates
if (state?.token) {
import('../services/api/client').then(({ apiClient }) => {
apiClient.setAuthToken(state.token!);
if (state.user?.tenant_id) {
apiClient.setTenantId(state.user.tenant_id);
}
});
}
},
}
)
);
@@ -255,6 +251,7 @@ export const usePermissions = () => useAuthStore((state) => ({
// Hook for auth actions
export const useAuthActions = () => useAuthStore((state) => ({
login: state.login,
register: state.register,
logout: state.logout,
refreshAuth: state.refreshAuth,
updateUser: state.updateUser,

View File

@@ -1,99 +0,0 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export type BakeryType = 'artisan' | 'dependent';
export type BusinessModel = 'production' | 'retail' | 'hybrid';
interface BakeryState {
// State
currentTenant: Tenant | null;
bakeryType: BakeryType;
businessModel: BusinessModel;
operatingHours: {
start: string;
end: string;
};
features: {
inventory: boolean;
production: boolean;
forecasting: boolean;
analytics: boolean;
pos: boolean;
};
// Actions
setTenant: (tenant: Tenant) => void;
setBakeryType: (type: BakeryType) => void;
setBusinessModel: (model: BusinessModel) => void;
updateFeatures: (features: Partial<BakeryState['features']>) => void;
reset: () => void;
}
interface Tenant {
id: string;
name: string;
subscription_plan: string;
created_at: string;
members_count: number;
}
const initialState = {
currentTenant: null,
bakeryType: 'artisan' as BakeryType,
businessModel: 'production' as BusinessModel,
operatingHours: {
start: '04:00',
end: '20:00',
},
features: {
inventory: true,
production: true,
forecasting: true,
analytics: true,
pos: false,
},
};
export const useBakeryStore = create<BakeryState>()(
devtools(
persist(
immer((set) => ({
...initialState,
setTenant: (tenant) =>
set((state) => {
state.currentTenant = tenant;
}),
setBakeryType: (type) =>
set((state) => {
state.bakeryType = type;
// Adjust features based on bakery type
if (type === 'artisan') {
state.features.pos = false;
state.businessModel = 'production';
} else if (type === 'dependent') {
state.features.pos = true;
state.businessModel = 'retail';
}
}),
setBusinessModel: (model) =>
set((state) => {
state.businessModel = model;
}),
updateFeatures: (features) =>
set((state) => {
state.features = { ...state.features, ...features };
}),
reset: () => set(() => initialState),
})),
{
name: 'bakery-storage',
}
)
)
);

View File

@@ -5,8 +5,9 @@ export type { User, AuthState } from './auth.store';
export { useUIStore, useLanguage, useSidebar, useCompactMode, useViewMode, useLoading, useToasts, useModals, useBreadcrumbs, usePreferences, useUIActions } from './ui.store';
export type { Theme, Language, ViewMode, SidebarState, Toast, Modal, UIState } from './ui.store';
export { useBakeryStore } from './bakery.store';
export type { BakeryType, BusinessModel } from './bakery.store';
export { useTenantStore, useCurrentTenant, useAvailableTenants, useTenantLoading, useTenantError, useTenantActions, useTenantPermissions, useTenant } from './tenant.store';
export type { TenantState } from './tenant.store';
export { useAlertsStore, useAlerts, useAlertRules, useAlertFilters, useAlertSettings, useUnreadAlertsCount, useCriticalAlertsCount } from './alerts.store';
export type { Alert, AlertRule, AlertCondition, AlertAction, AlertsState, AlertType, AlertCategory, AlertPriority, AlertStatus } from './alerts.store';

View File

@@ -0,0 +1,230 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { tenantService, TenantResponse } from '../services/api/tenant.service';
import { useAuthUser } from './auth.store';
export interface TenantState {
// State
currentTenant: TenantResponse | null;
availableTenants: TenantResponse[] | null;
isLoading: boolean;
error: string | null;
// Actions
setCurrentTenant: (tenant: TenantResponse) => void;
switchTenant: (tenantId: string) => Promise<boolean>;
loadUserTenants: () => Promise<void>;
clearTenants: () => void;
clearError: () => void;
setLoading: (loading: boolean) => void;
// Permission helpers (migrated from BakeryContext)
hasPermission: (permission: string) => boolean;
canAccess: (resource: string, action: string) => boolean;
}
export const useTenantStore = create<TenantState>()(
persist(
(set, get) => ({
// Initial state
currentTenant: null,
availableTenants: null,
isLoading: false,
error: null,
// Actions
setCurrentTenant: (tenant: TenantResponse) => {
set({ currentTenant: tenant });
// Update API client with new tenant ID
tenantService.setCurrentTenant(tenant);
},
switchTenant: async (tenantId: string): Promise<boolean> => {
try {
set({ isLoading: true, error: null });
const { availableTenants } = get();
// Find tenant in available tenants first
const targetTenant = availableTenants?.find(t => t.id === tenantId);
if (!targetTenant) {
throw new Error('Tenant not found in available tenants');
}
// Switch tenant using service
const response = await tenantService.switchTenant(tenantId);
if (response.success && response.data?.tenant) {
get().setCurrentTenant(response.data.tenant);
set({ isLoading: false });
return true;
} else {
throw new Error(response.error || 'Failed to switch tenant');
}
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to switch tenant',
});
return false;
}
},
loadUserTenants: async (): Promise<void> => {
try {
set({ isLoading: true, error: null });
// Get current user to determine user ID
const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user;
if (!user?.id) {
throw new Error('User not authenticated');
}
const response = await tenantService.getUserTenants(user.id);
if (response.success && response.data) {
const tenants = Array.isArray(response.data) ? response.data : [response.data];
set({
availableTenants: tenants,
isLoading: false
});
// If no current tenant is set, set the first one as current
const { currentTenant } = get();
if (!currentTenant && tenants.length > 0) {
get().setCurrentTenant(tenants[0]);
}
} else {
throw new Error(response.error || 'Failed to load user tenants');
}
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tenants',
});
}
},
clearTenants: () => {
set({
currentTenant: null,
availableTenants: null,
error: null,
});
tenantService.clearCurrentTenant();
},
clearError: () => {
set({ error: null });
},
setLoading: (loading: boolean) => {
set({ isLoading: loading });
},
// Permission helpers (migrated from BakeryContext)
hasPermission: (permission: string): boolean => {
const { currentTenant } = get();
if (!currentTenant) return false;
// Get user to determine role within this tenant
const user = useAuthUser.getState?.() || JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.user;
// Admin role has all permissions
if (user?.role === 'admin') return true;
// TODO: Implement proper tenant-based permissions
// For now, use basic role-based permissions
switch (user?.role) {
case 'admin':
return true;
case 'manager':
return ['inventory', 'production', 'sales', 'reports'].some(resource =>
permission.startsWith(resource)
);
case 'baker':
return ['production', 'inventory'].some(resource =>
permission.startsWith(resource)
) && !permission.includes(':delete');
case 'staff':
return ['inventory', 'sales'].some(resource =>
permission.startsWith(resource)
) && permission.includes(':read');
default:
return false;
}
},
canAccess: (resource: string, action: string): boolean => {
const { hasPermission } = get();
// Check specific permission
if (hasPermission(`${resource}:${action}`)) return true;
// Check wildcard permissions
if (hasPermission(`${resource}:*`)) return true;
return false;
},
}),
{
name: 'tenant-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
currentTenant: state.currentTenant,
availableTenants: state.availableTenants,
}),
onRehydrateStorage: () => (state) => {
// Initialize API client with stored tenant when store rehydrates
if (state?.currentTenant) {
import('../services/api/client').then(({ apiClient }) => {
apiClient.setTenantId(state.currentTenant!.id);
});
}
},
}
)
);
// Selectors for common use cases
export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant);
export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants);
export const useTenantLoading = () => useTenantStore((state) => state.isLoading);
export const useTenantError = () => useTenantStore((state) => state.error);
// Hook for tenant actions
export const useTenantActions = () => useTenantStore((state) => ({
setCurrentTenant: state.setCurrentTenant,
switchTenant: state.switchTenant,
loadUserTenants: state.loadUserTenants,
clearTenants: state.clearTenants,
clearError: state.clearError,
setLoading: state.setLoading,
}));
// Hook for tenant permissions (replaces useBakeryPermissions)
export const useTenantPermissions = () => useTenantStore((state) => ({
hasPermission: state.hasPermission,
canAccess: state.canAccess,
}));
// Combined hook for convenience
export const useTenant = () => {
const currentTenant = useCurrentTenant();
const availableTenants = useAvailableTenants();
const isLoading = useTenantLoading();
const error = useTenantError();
const actions = useTenantActions();
const permissions = useTenantPermissions();
return {
currentTenant,
availableTenants,
isLoading,
error,
...actions,
...permissions,
};
};

View File

@@ -1,29 +1,25 @@
// Authentication related types
// Authentication related types - Updated to match backend exactly
export interface User {
id: string;
email: string;
full_name: string;
full_name: string; // Backend uses full_name, not name
is_active: boolean;
is_verified: boolean;
created_at: string;
created_at: string; // ISO format datetime string
last_login?: string;
phone?: string;
language?: string;
timezone?: string;
tenant_id?: string;
role?: UserRole;
avatar_url?: string;
role?: string; // Backend uses string, not enum
}
export interface UserRegistration {
email: string;
password: string;
full_name: string;
tenant_name?: string;
role?: UserRole;
phone?: string;
language?: string;
timezone?: string;
tenant_name?: string; // Optional in backend
role?: string; // Backend uses string, defaults to "user"
}
export interface UserLogin {
@@ -35,8 +31,8 @@ export interface UserLogin {
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
token_type: string; // defaults to "bearer"
expires_in: number; // seconds, defaults to 3600
user?: User;
}
@@ -94,7 +90,7 @@ export interface RegisterFormData {
password: string;
confirmPassword: string;
full_name: string;
tenant_name: string;
tenant_name?: string; // Optional to match backend
phone?: string;
acceptTerms: boolean;
}
@@ -182,13 +178,11 @@ export interface OAuthProvider {
enabled: boolean;
}
// Enums
// Enums - Simplified to match backend
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
MANAGER = 'manager',
OWNER = 'owner',
VIEWER = 'viewer',
}
export enum AuthProvider {