[MCH-FE] feat: Add dark mode theme support
- Add ThemeContext for system/light/dark theme management - Update Layout with theme toggle button - Add dark mode styles to components - Update Tailwind config for dark mode support - Add CSS transitions for smooth theme switching Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b1e75b8618
commit
777693c7dd
65
src/App.tsx
65
src/App.tsx
@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { Layout } from './components/Layout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Products } from './pages/Products';
|
||||
@ -59,39 +60,41 @@ function PublicRoute() {
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Route>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="customers" element={<Customers />} />
|
||||
<Route path="fiado" element={<Fiado />} />
|
||||
<Route path="inventory" element={<Inventory />} />
|
||||
<Route path="referrals" element={<Referrals />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="marketplace" element={<Marketplace />} />
|
||||
<Route path="tokens" element={<Tokens />} />
|
||||
<Route path="codi-spei" element={<CodiSpei />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route element={<PublicRoute />}>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
{/* Protected routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="customers" element={<Customers />} />
|
||||
<Route path="fiado" element={<Fiado />} />
|
||||
<Route path="inventory" element={<Inventory />} />
|
||||
<Route path="referrals" element={<Referrals />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="marketplace" element={<Marketplace />} />
|
||||
<Route path="tokens" element={<Tokens />} />
|
||||
<Route path="codi-spei" element={<CodiSpei />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,10 +16,13 @@ import {
|
||||
Truck,
|
||||
Coins,
|
||||
QrCode,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
@ -39,6 +42,7 @@ const navigation = [
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { user, tenant, logout } = useAuth();
|
||||
const { resolvedTheme, toggleTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
@ -56,7 +60,7 @@ export function Layout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
||||
{/* Mobile sidebar */}
|
||||
<div
|
||||
className={clsx(
|
||||
@ -68,13 +72,13 @@ export function Layout() {
|
||||
className="fixed inset-0 bg-gray-900/80"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="fixed inset-y-0 left-0 w-64 bg-white shadow-xl">
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b">
|
||||
<div className="fixed inset-y-0 left-0 w-64 bg-white dark:bg-gray-800 shadow-xl">
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="h-8 w-8 text-primary-500" />
|
||||
<span className="font-bold text-lg">MiChangarrito</span>
|
||||
<span className="font-bold text-lg dark:text-gray-100">MiChangarrito</span>
|
||||
</div>
|
||||
<button onClick={() => setSidebarOpen(false)}>
|
||||
<button onClick={() => setSidebarOpen(false)} className="dark:text-gray-300">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
@ -88,8 +92,8 @@ export function Layout() {
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
@ -103,12 +107,23 @@ export function Layout() {
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
||||
<div className="flex h-16 items-center px-4 border-b">
|
||||
<div className="flex flex-col flex-grow bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transition-colors duration-200">
|
||||
<div className="flex h-16 items-center justify-between px-4 border-b dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="h-8 w-8 text-primary-500" />
|
||||
<span className="font-bold text-lg">MiChangarrito</span>
|
||||
<span className="font-bold text-lg dark:text-gray-100">MiChangarrito</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title={resolvedTheme === 'dark' ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
@ -119,8 +134,8 @@ export function Layout() {
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
|
||||
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
)
|
||||
}
|
||||
>
|
||||
@ -129,22 +144,22 @@ export function Layout() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t">
|
||||
<div className="p-4 border-t dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<span className="text-emerald-600 font-semibold">
|
||||
<div className="h-10 w-10 rounded-full bg-emerald-100 dark:bg-emerald-900/50 flex items-center justify-center">
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-semibold">
|
||||
{tenant ? getInitials(tenant.name) : 'MC'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{tenant?.name || 'Mi Negocio'}</p>
|
||||
<p className="text-xs text-gray-500">{user?.name}</p>
|
||||
<p className="text-sm font-medium dark:text-gray-100">{tenant?.name || 'Mi Negocio'}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{user?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
className="p-2 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Cerrar sesion"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
@ -157,14 +172,27 @@ export function Layout() {
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Mobile header */}
|
||||
<div className="sticky top-0 z-40 flex h-16 items-center gap-4 bg-white border-b px-4 lg:hidden">
|
||||
<button onClick={() => setSidebarOpen(true)}>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="h-6 w-6 text-primary-500" />
|
||||
<span className="font-bold">MiChangarrito</span>
|
||||
<div className="sticky top-0 z-40 flex h-16 items-center justify-between gap-4 bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-4 lg:hidden transition-colors duration-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => setSidebarOpen(true)} className="dark:text-gray-300">
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Store className="h-6 w-6 text-primary-500" />
|
||||
<span className="font-bold dark:text-gray-100">MiChangarrito</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title={resolvedTheme === 'dark' ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
|
||||
103
src/contexts/ThemeContext.tsx
Normal file
103
src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'michangarrito-theme';
|
||||
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(getStoredTheme);
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
// Resolve the actual theme (handles 'system' preference)
|
||||
useEffect(() => {
|
||||
const resolved = theme === 'system' ? getSystemTheme() : theme;
|
||||
setResolvedTheme(resolved);
|
||||
|
||||
// Apply class to document
|
||||
const root = document.documentElement;
|
||||
if (resolved === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(newTheme);
|
||||
|
||||
const root = document.documentElement;
|
||||
if (newTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = resolvedTheme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -26,7 +26,12 @@
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
@apply bg-gray-50 text-gray-900 transition-colors duration-200;
|
||||
}
|
||||
|
||||
.dark body,
|
||||
html.dark body {
|
||||
@apply bg-gray-900 text-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,13 +46,16 @@
|
||||
|
||||
.btn-outline {
|
||||
@apply border border-gray-300 hover:border-gray-400 text-gray-700 font-medium py-2 px-4 rounded-lg transition-colors;
|
||||
@apply dark:border-gray-600 dark:hover:border-gray-500 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-sm border border-gray-200 p-4;
|
||||
@apply dark:bg-gray-800 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500;
|
||||
@apply dark:bg-gray-800 dark:border-gray-600 dark:text-gray-100 dark:placeholder-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user