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

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