Start integrating the onboarding flow with backend 1
This commit is contained in:
@@ -1,312 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import { ChevronDown, Building, Check, Plus } from 'lucide-react';
|
||||
import { Button } from '../Button';
|
||||
import { Avatar } from '../Avatar';
|
||||
|
||||
interface Bakery {
|
||||
id: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
role: 'owner' | 'manager' | 'baker' | 'staff';
|
||||
status: 'active' | 'inactive';
|
||||
address?: string;
|
||||
}
|
||||
|
||||
interface BakerySelectorProps {
|
||||
bakeries: Bakery[];
|
||||
selectedBakery: Bakery;
|
||||
onSelectBakery: (bakery: Bakery) => void;
|
||||
onAddBakery?: () => void;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
owner: 'Propietario',
|
||||
manager: 'Gerente',
|
||||
baker: 'Panadero',
|
||||
staff: 'Personal'
|
||||
};
|
||||
|
||||
const roleColors = {
|
||||
owner: 'text-color-success',
|
||||
manager: 'text-color-info',
|
||||
baker: 'text-color-warning',
|
||||
staff: 'text-text-secondary'
|
||||
};
|
||||
|
||||
export const BakerySelector: React.FC<BakerySelectorProps> = ({
|
||||
bakeries,
|
||||
selectedBakery,
|
||||
onSelectBakery,
|
||||
onAddBakery,
|
||||
className,
|
||||
size = 'md'
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Calculate dropdown position when opening
|
||||
useEffect(() => {
|
||||
if (isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const isMobile = viewportWidth < 640; // sm breakpoint
|
||||
|
||||
let top = rect.bottom + window.scrollY + 8;
|
||||
let left = rect.left + window.scrollX;
|
||||
let width = Math.max(rect.width, isMobile ? viewportWidth - 32 : 320); // 16px margin on each side for mobile
|
||||
|
||||
// Adjust for mobile - center dropdown with margins
|
||||
if (isMobile) {
|
||||
left = 16; // 16px margin from left
|
||||
width = viewportWidth - 32; // 16px margins on both sides
|
||||
} else {
|
||||
// Adjust horizontal position to prevent overflow
|
||||
const dropdownWidth = Math.max(width, 320);
|
||||
if (left + dropdownWidth > viewportWidth - 16) {
|
||||
left = viewportWidth - dropdownWidth - 16;
|
||||
}
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust vertical position if dropdown would overflow bottom
|
||||
const dropdownMaxHeight = 320; // Approximate max height
|
||||
const headerHeight = 64; // Approximate header height
|
||||
|
||||
if (top + dropdownMaxHeight > viewportHeight + window.scrollY - 16) {
|
||||
// Try to position above the button
|
||||
const topPosition = rect.top + window.scrollY - dropdownMaxHeight - 8;
|
||||
|
||||
// Ensure it doesn't go above the header
|
||||
if (topPosition < window.scrollY + headerHeight) {
|
||||
// If it can't fit above, position it at the top of the visible area
|
||||
top = window.scrollY + headerHeight + 8;
|
||||
} else {
|
||||
top = topPosition;
|
||||
}
|
||||
}
|
||||
|
||||
setDropdownPosition({ top, left, width });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (buttonRef.current && !buttonRef.current.contains(event.target as Node) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on escape key and handle body scroll lock
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Prevent body scroll on mobile when dropdown is open
|
||||
const isMobile = window.innerWidth < 640;
|
||||
if (isMobile) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
// Restore body scroll
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-10 px-3 text-sm sm:h-8', // Always at least 40px (10) for better touch targets on mobile
|
||||
md: 'h-12 px-4 text-base sm:h-10', // 48px (12) on mobile, 40px on desktop
|
||||
lg: 'h-14 px-5 text-lg sm:h-12' // 56px (14) on mobile, 48px on desktop
|
||||
};
|
||||
|
||||
const avatarSizes = {
|
||||
sm: 'sm' as const, // Changed from xs to sm for better mobile visibility
|
||||
md: 'sm' as const,
|
||||
lg: 'md' as const
|
||||
};
|
||||
|
||||
const getBakeryInitials = (name: string) => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)} ref={dropdownRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 sm:gap-3 bg-[var(--bg-primary)] border border-[var(--border-primary)]',
|
||||
'rounded-lg transition-all duration-200 hover:bg-[var(--bg-secondary)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]',
|
||||
'active:scale-[0.98] w-full',
|
||||
sizeClasses[size],
|
||||
isOpen && 'ring-2 ring-[var(--color-primary)]/20 border-[var(--color-primary)]'
|
||||
)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={`Panadería seleccionada: ${selectedBakery.name}`}
|
||||
>
|
||||
<Avatar
|
||||
src={selectedBakery.logo}
|
||||
name={selectedBakery.name}
|
||||
size={avatarSizes[size]}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="text-[var(--text-primary)] font-medium truncate text-sm sm:text-base">
|
||||
{selectedBakery.name}
|
||||
</div>
|
||||
{size !== 'sm' && (
|
||||
<div className={clsx('text-xs truncate hidden sm:block', roleColors[selectedBakery.role])}>
|
||||
{roleLabels[selectedBakery.role]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
'flex-shrink-0 transition-transform duration-200 text-[var(--text-secondary)]',
|
||||
size === 'sm' ? 'w-4 h-4' : 'w-4 h-4', // Consistent sizing
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-[9998] sm:hidden"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-xl py-2 z-[9999] sm:min-w-80 sm:max-w-96"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2 text-xs font-medium text-[var(--text-tertiary)] border-b border-[var(--border-primary)]">
|
||||
Mis Panaderías ({bakeries.length})
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 sm:max-h-64 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-[var(--border-secondary)] scrollbar-track-transparent">
|
||||
{bakeries.map((bakery) => (
|
||||
<button
|
||||
key={bakery.id}
|
||||
onClick={() => {
|
||||
onSelectBakery(bakery);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px]',
|
||||
'hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors',
|
||||
'focus:outline-none focus:bg-[var(--bg-secondary)]',
|
||||
'touch-manipulation', // Improves touch responsiveness
|
||||
selectedBakery.id === bakery.id && 'bg-[var(--bg-secondary)]'
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
src={bakery.logo}
|
||||
name={bakery.name}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--text-primary)] font-medium truncate">
|
||||
{bakery.name}
|
||||
</span>
|
||||
{selectedBakery.id === bakery.id && (
|
||||
<Check className="w-4 h-4 text-[var(--color-primary)] flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={clsx('text-xs', roleColors[bakery.role])}>
|
||||
{roleLabels[bakery.role]}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--text-tertiary)]">•</span>
|
||||
<span className={clsx(
|
||||
'text-xs',
|
||||
bakery.status === 'active' ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
||||
)}>
|
||||
{bakery.status === 'active' ? 'Activa' : 'Inactiva'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bakery.address && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] truncate mt-1">
|
||||
{bakery.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onAddBakery && (
|
||||
<>
|
||||
<div className="border-t border-[var(--border-primary)] my-2"></div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onAddBakery();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-3 py-4 text-left min-h-[60px] sm:py-3 sm:min-h-[48px] hover:bg-[var(--bg-secondary)] active:bg-[var(--bg-tertiary)] transition-colors text-[var(--color-primary)] touch-manipulation"
|
||||
>
|
||||
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="font-medium">Agregar Panadería</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BakerySelector;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BakerySelector } from './BakerySelector';
|
||||
export type { default as BakerySelector } from './BakerySelector';
|
||||
131
frontend/src/components/ui/PasswordCriteria.tsx
Normal file
131
frontend/src/components/ui/PasswordCriteria.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PasswordCriteria {
|
||||
label: string;
|
||||
isValid: boolean;
|
||||
regex?: RegExp;
|
||||
checkFn?: (password: string) => boolean;
|
||||
}
|
||||
|
||||
interface PasswordCriteriaProps {
|
||||
password: string;
|
||||
className?: string;
|
||||
showOnlyFailed?: boolean;
|
||||
}
|
||||
|
||||
export const PasswordCriteria: React.FC<PasswordCriteriaProps> = ({
|
||||
password,
|
||||
className = '',
|
||||
showOnlyFailed = false
|
||||
}) => {
|
||||
const criteria: PasswordCriteria[] = [
|
||||
{
|
||||
label: 'Al menos 8 caracteres',
|
||||
isValid: password.length >= 8,
|
||||
checkFn: (pwd) => pwd.length >= 8
|
||||
},
|
||||
{
|
||||
label: 'Máximo 128 caracteres',
|
||||
isValid: password.length <= 128,
|
||||
checkFn: (pwd) => pwd.length <= 128
|
||||
},
|
||||
{
|
||||
label: 'Al menos una letra mayúscula',
|
||||
isValid: /[A-Z]/.test(password),
|
||||
regex: /[A-Z]/
|
||||
},
|
||||
{
|
||||
label: 'Al menos una letra minúscula',
|
||||
isValid: /[a-z]/.test(password),
|
||||
regex: /[a-z]/
|
||||
},
|
||||
{
|
||||
label: 'Al menos un número',
|
||||
isValid: /\d/.test(password),
|
||||
regex: /\d/
|
||||
}
|
||||
];
|
||||
|
||||
const validatedCriteria = criteria.map(criterion => ({
|
||||
...criterion,
|
||||
isValid: criterion.regex
|
||||
? criterion.regex.test(password)
|
||||
: criterion.checkFn
|
||||
? criterion.checkFn(password)
|
||||
: false
|
||||
}));
|
||||
|
||||
const displayCriteria = showOnlyFailed
|
||||
? validatedCriteria.filter(c => !c.isValid)
|
||||
: validatedCriteria;
|
||||
|
||||
if (displayCriteria.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`text-sm space-y-1 ${className}`}>
|
||||
<p className="text-text-secondary font-medium mb-2">
|
||||
Requisitos de contraseña:
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{displayCriteria.map((criterion, index) => (
|
||||
<li key={index} className="flex items-center space-x-2">
|
||||
<span
|
||||
className={`w-4 h-4 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
criterion.isValid
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{criterion.isValid ? '✓' : '✗'}
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
criterion.isValid
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
{criterion.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const validatePassword = (password: string): boolean => {
|
||||
return (
|
||||
password.length >= 8 &&
|
||||
password.length <= 128 &&
|
||||
/[A-Z]/.test(password) &&
|
||||
/[a-z]/.test(password) &&
|
||||
/\d/.test(password)
|
||||
);
|
||||
};
|
||||
|
||||
export const getPasswordErrors = (password: string): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('La contraseña debe tener al menos 8 caracteres');
|
||||
}
|
||||
|
||||
if (password.length > 128) {
|
||||
errors.push('La contraseña no puede exceder 128 caracteres');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos una letra mayúscula');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos una letra minúscula');
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('La contraseña debe contener al menos un número');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
215
frontend/src/components/ui/TenantSwitcher.tsx
Normal file
215
frontend/src/components/ui/TenantSwitcher.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTenant } from '../../stores/tenant.store';
|
||||
import { useToast } from '../../hooks/ui/useToast';
|
||||
import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface TenantSwitcherProps {
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
className = '',
|
||||
showLabel = true,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const {
|
||||
currentTenant,
|
||||
availableTenants,
|
||||
isLoading,
|
||||
error,
|
||||
switchTenant,
|
||||
loadUserTenants,
|
||||
clearError,
|
||||
} = useTenant();
|
||||
|
||||
const { success: showSuccessToast, error: showErrorToast } = useToast();
|
||||
|
||||
// Load tenants on mount
|
||||
useEffect(() => {
|
||||
if (!availableTenants) {
|
||||
loadUserTenants();
|
||||
}
|
||||
}, [availableTenants, loadUserTenants]);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
!buttonRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle tenant switch
|
||||
const handleTenantSwitch = async (tenantId: string) => {
|
||||
if (tenantId === currentTenant?.id) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await switchTenant(tenantId);
|
||||
setIsOpen(false);
|
||||
|
||||
if (success) {
|
||||
const newTenant = availableTenants?.find(t => t.id === tenantId);
|
||||
showSuccessToast(`Switched to ${newTenant?.name}`, {
|
||||
title: 'Tenant Switched'
|
||||
});
|
||||
} else {
|
||||
showErrorToast(error || 'Failed to switch tenant', {
|
||||
title: 'Switch Failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle retry loading tenants
|
||||
const handleRetry = () => {
|
||||
clearError();
|
||||
loadUserTenants();
|
||||
};
|
||||
|
||||
// Don't render if no tenants available
|
||||
if (!availableTenants || availableTenants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render if only one tenant
|
||||
if (availableTenants.length === 1) {
|
||||
return showLabel ? (
|
||||
<div className={`flex items-center space-x-2 text-text-secondary ${className}`}>
|
||||
<Building2 className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{currentTenant?.name}</span>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-text-primary bg-bg-secondary hover:bg-bg-tertiary border border-border-secondary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Switch tenant"
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-text-secondary" />
|
||||
{showLabel && (
|
||||
<span className="hidden sm:block max-w-32 truncate">
|
||||
{currentTenant?.name || 'Select Tenant'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-text-secondary transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 mt-2 w-72 bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-50"
|
||||
role="listbox"
|
||||
aria-label="Available tenants"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border-primary">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Switch Organization</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Select the organization you want to work with
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="px-3 py-2 border-b border-border-primary">
|
||||
<div className="flex items-center space-x-2 text-color-error text-xs">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="ml-auto text-color-primary hover:text-color-primary-dark underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tenant List */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{availableTenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
onClick={() => handleTenantSwitch(tenant.id)}
|
||||
disabled={isLoading}
|
||||
className="w-full px-3 py-3 text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
role="option"
|
||||
aria-selected={tenant.id === currentTenant?.id}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-color-primary/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Building2 className="w-4 h-4 text-color-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{tenant.name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary truncate">
|
||||
{tenant.business_type} • {tenant.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tenant.id === currentTenant?.id && (
|
||||
<Check className="w-4 h-4 text-color-success flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-2 border-t border-border-primary bg-bg-secondary rounded-b-lg">
|
||||
<p className="text-xs text-text-secondary">
|
||||
Need to add a new organization?{' '}
|
||||
<button className="text-color-primary hover:text-color-primary-dark underline">
|
||||
Contact Support
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-bg-primary/50 rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-2 border-color-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export { ListItem } from './ListItem';
|
||||
export { StatsCard, StatsGrid } from './Stats';
|
||||
export { StatusCard, getStatusColor } from './StatusCard';
|
||||
export { StatusModal } from './StatusModal';
|
||||
export { BakerySelector } from './BakerySelector';
|
||||
export { TenantSwitcher } from './TenantSwitcher';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
|
||||
Reference in New Issue
Block a user