React frontend patterns for Pricing App - Zustand, CSS Modules, Tesla Design System, permissions
import { useState, useEffect } from 'react'import React from 'react' or import * as Reactfunction Button({ label, onClick })import styles from './Component.module.css'design-tokens.css: var(--bg-primary), var(--text-primary)buttons-tesla.css, modals-tesla.css, table-tesla.css)services/api.jslocalStorage.getItem('token')lucide-react for all icons: import { Package, Check, X } from 'lucide-react'react-icons for new code β standardized on lucide-reactonClick to .modalOverlay to close β users lose data clicking outside accidentallystopPropagation on .modalContent as a workaround for overlay click-to-close<img src="logo.png" alt="Company logo" /><button> not <div onClick><button aria-label="Close modal">frontend/src/
βββ pages/ # Full page components
β βββ Productos.jsx
β βββ Ventas.jsx
β βββ Admin.jsx
βββ components/ # Reusable components
β βββ ModalTesla.jsx
β βββ PricingModal.jsx
β βββ Navbar.jsx
β βββ turbo/ # Domain-specific components
βββ contexts/ # React contexts
β βββ ThemeContext.jsx # Dark mode
β βββ PermisosContext.jsx # User permissions
βββ hooks/ # Custom hooks
β βββ useDebounce.js
β βββ usePermisos.js
β βββ useServerPagination.js
βββ store/ # Zustand stores
β βββ authStore.js # Auth state
βββ services/ # API client
β βββ api.js # Axios instance
βββ styles/ # Global CSS, design tokens
βββ design-tokens.css
βββ buttons-tesla.css
βββ modals-tesla.css
βββ table-tesla.css
import { useState, useEffect } from 'react';
import styles from './ProductosList.module.css';
export default function ProductosList({ onSelect }) {
const [productos, setProductos] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
fetchProductos();
}, []);
const fetchProductos = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/productos');
const data = await response.json();
setProductos(data);
} catch (err) {
setError('Error al cargar productos');
console.error(err);
} finally {
setLoading(false);
}
};
if (loading) return <div className={styles.loading}>Cargando...</div>;
if (error) return <div className={styles.error}>{error}</div>;
return (
<div className={styles.container}>
{productos.map(p => (
<div key={p.id} onClick={() => onSelect(p)}>
{p.descripcion}
</div>
))}
</div>
);
}
import { create } from 'zustand';
export const useAuthStore = create((set) => ({
user: null,
token: localStorage.getItem('token'),
setUser: (user) => set({ user }),
setToken: (token) => {
localStorage.setItem('token', token);
set({ token });
},
logout: () => {
localStorage.removeItem('token');
set({ user: null, token: null });
}
}));
// Usage in component
import { useAuthStore } from '@/store/authStore';
function Navbar() {
const { user, logout } = useAuthStore();
return (
<nav>
<span>{user?.nombre}</span>
<button onClick={logout}>Salir</button>
</nav>
);
}
import { createContext, useContext, useState, useEffect } from 'react';
import { useAuthStore } from '@/store/authStore';
const PermisosContext = createContext();
export function PermisosProvider({ children }) {
const { user } = useAuthStore();
const [permisos, setPermisos] = useState([]);
useEffect(() => {
if (user?.roles) {
// Calculate permissions from roles
const allPermisos = user.roles.flatMap(role =>
PERMISOS_CATEGORIAS[role] || []
);
setPermisos([...new Set(allPermisos)]);
}
}, [user]);
const tienePermiso = (categoria) => permisos.includes(categoria);
return (
<PermisosContext.Provider value={{ permisos, tienePermiso }}>
{children}
</PermisosContext.Provider>
);
}
export const usePermisos = () => useContext(PermisosContext);
// Usage in component
function AdminPanel() {
const { tienePermiso } = usePermisos();
if (!tienePermiso('config')) {
return <div>No tienes permiso</div>;
}
return <div>Panel de Admin</div>;
}
/* ProductosList.module.css */
.container {
background: var(--bg-primary);
color: var(--text-primary);
padding: var(--spacing-md);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.loading {
color: var(--text-secondary);
text-align: center;
padding: var(--spacing-lg);
}
.error {
background: var(--error-bg);
color: var(--error-text);
padding: var(--spacing-sm);
border-radius: var(--radius-sm);
}
/* Dark mode support (automatic via design tokens) */
import styles from './Button.module.css';
export default function Button({
label,
onClick,
variant = 'primary',
disabled = false
}) {
return (
<button
className={`${styles.btnBase} ${styles[`btn${variant.charAt(0).toUpperCase() + variant.slice(1)}`]}`}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}
/* Button.module.css */
.btnBase {
composes: btn-base from '../../styles/buttons-tesla.css';
}
.btnPrimary {
composes: btn-primary from '../../styles/buttons-tesla.css';
}
.btnSecondary {
composes: btn-secondary from '../../styles/buttons-tesla.css';
}
// hooks/useDebounce.js
import { useState, useEffect } from 'react';
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchBar() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
| Entity | Pattern | Example |
|---|---|---|
| Component file | PascalCase.jsx |
ProductosList.jsx |
| Hook file | camelCase.js |
useDebounce.js |
| Utility file | camelCase.js |
formatCurrency.js |
| CSS Module | PascalCase.module.css |
ProductosList.module.css |
| CSS class | camelCase |
.btnPrimary, .modalHeader |
useEffect without dependencies array β Will cause infinite loopsvar(--color-name)# Development
cd frontend
npm install
npm run dev
# Build
npm run build
npm run preview
# Linting
npm run lint
Components:
Sidebar.jsx: Main sidebar with 3 states (expanded/collapsed/hover-peek)SidebarSection.jsx: Collapsible section with menu itemsTopBar.jsx: Minimal top headerAppLayout.jsx: Layout wrapperStates:
Pattern:
import Sidebar from '@/components/Sidebar';
import TopBar from '@/components/TopBar';
import AppLayout from '@/components/AppLayout';
// En App.jsx o router
<AppLayout>
<Outlet />
</AppLayout>
Adding menu items:
Edit Sidebar.jsx and add to menuSections array:
{
id: 'productos',
title: 'Productos',
icon: 'π¦',
defaultOpen: true,
items: [
{ label: 'Productos', path: '/productos', permiso: 'productos.ver' },
// ...
],
}
Variants:
default: Standard padding (20px)compact: Small padding (16px)metric: For dashboard metricsPattern:
import CloudflareCard, { MetricCard } from '@/components/CloudflareCard';
// Standard card
<CloudflareCard
title="Dominios"
action={<button>+ Agregar</button>}
>
<p>Content here</p>
</CloudflareCard>
// Metric card for dashboards
<MetricCard
label="Solicitudes HTTP"
value="138,3k"
trend="+3.65%"
trendDirection="up"
chart={<Sparkline data={[...]} />}
info="InformaciΓ³n adicional"
/>
Styling:
var(--cf-card-bg), var(--cf-card-border)var(--cf-card-shadow)Variants:
info: Blue background (default)warning: Orange backgroundsuccess: Green backgrounderror: Red backgroundPattern:
import AlertBanner, { AlertBannerContainer } from '@/components/AlertBanner';
<AlertBannerContainer>
<AlertBanner
id="unique-banner-id"
variant="info"
message="Mensaje informativo aquΓ"
action={{
label: 'Ver mΓ‘s',
onClick: () => navigate('/page')
}}
dismissible={true}
/>
</AlertBannerContainer>
Features:
dismissible={false}persistent={true} to ignore localStorageNOTE: AlertBanners will be managed from backend admin panel (dynamic system TBD).
Theme-aware tokens (adapt to light/dark mode):
Dark Mode (default):
/* Backgrounds */
--cf-bg-app: #000000
--cf-bg-sidebar: #0a0a0a
--cf-bg-card: #181818
--cf-bg-hover: #1f1f1f
--cf-bg-topbar: #0a0a0a
/* Borders */
--cf-border-subtle: #1a1a1a
--cf-border-default: #2a2a2a
/* Text */
--cf-text-primary: #ffffff
--cf-text-secondary: rgba(255, 255, 255, 0.7)
--cf-text-tertiary: rgba(255, 255, 255, 0.5)
--cf-text-muted: rgba(255, 255, 255, 0.4)
Light Mode:
/* Backgrounds */
--cf-bg-app: #f9fafb
--cf-bg-sidebar: #ffffff
--cf-bg-card: #ffffff
--cf-bg-hover: #f3f4f6
--cf-bg-topbar: #ffffff
/* Borders */
--cf-border-subtle: #e5e7eb
--cf-border-default: #d1d5db
/* Text */
--cf-text-primary: #111827
--cf-text-secondary: #4b5563
--cf-text-tertiary: #6b7280
--cf-text-muted: #9ca3af
Theme-independent tokens:
/* Accent - Same in both themes */
--cf-accent-blue: #3b82f6
--cf-accent-blue-hover: #60a5fa
/* Layout */
--cf-sidebar-width-expanded: 240px
--cf-sidebar-width-collapsed: 64px
--cf-topbar-height: 56px
/* Typography */
--font-extralight: 200
--font-light: 300
--font-normal: 400
--font-medium: 500
--font-semibold: 600
--font-bold: 700
/* Cards */
--cf-card-padding-compact: 16px
--cf-card-padding-default: 20px
--cf-card-radius: 8px
Typography Guidelines:
Theme Toggle:
theme and toggleTheme()data-theme on <html> element--cf-* tokens adapt automaticallyComponent Overrides:
When tokens alone aren't enough, use :global([data-theme="light"]) in CSS Modules:
/* Dark mode (default) */
.logoIcon {
opacity: 1;
}
/* Light mode override */
:global([data-theme="light"]) .logoIcon {
filter: invert(1) brightness(0); /* White logo β Black logo */
}
Common light mode patterns:
filter: invert(1) brightness(0) for whiteβblackrgba(17, 24, 39, 0.1) instead of rgba(255, 255, 255, 0.05)Testing checklist:
Code Quality:
import React statementsAccessibility:
Permissions:
Theme Support:
Layout:
