Saltar al contenido principal

🎨 Frontend Profesional y Directo - Guías de Implementación

⚡ Filosofía

IMPORTANTE: Este proyecto se paga por la funcionalidad del CORE, no por el frontend elaborado.

Aclaración Importante

"Frontend sencillo" NO significa "mal hecho". Significa:

  • ✅ Seguir las mejores prácticas de Next.js 16
  • ✅ Usar App Router correctamente (Server Components, Server Actions)
  • ✅ Usar Tailwind CSS profesionalmente
  • ✅ Código limpio y mantenible
  • ❌ Pero SIN librerías UI complejas innecesarias

Reglas de Oro

  1. Next.js App Router (estándar) - Estructura profesional de Next.js
  2. Server Components por defecto - La forma correcta de Next.js 16
  3. Server Actions - Para mutations (estándar de Next.js)
  4. Tailwind CSS - Styling profesional
  5. HTML semántico - Forms nativos (funcionan perfecto con Server Actions)
  6. NO usar: Radix UI, shadcn/ui, Framer Motion (innecesarios para este MVP)
  7. NO crear: Component libraries complejas, design systems

🎯 Principios de UI

Prioridad de Esfuerzo

80% - Core (RBAC, validaciones, DB, tests)
15% - Formularios funcionales
5% - Estilos básicos con Tailwind

Lo Que SÍ Necesitamos

  • ✅ Formularios que funcionen
  • ✅ Tablas para mostrar datos
  • ✅ Botones que ejecuten acciones
  • ✅ Mensajes de error/éxito
  • ✅ Navegación básica
  • Actualizaciones optimistas (patrón estándar para mejor UX)

Lo Que NO Necesitamos

  • ❌ Diseño pixel-perfect
  • ❌ Animaciones o transiciones
  • ❌ Componentes reutilizables complejos
  • ❌ Sistema de themes (dark/light)
  • ❌ Responsive design elaborado
  • ❌ Accesibilidad avanzada (ARIA)

📝 Ejemplos de Implementación

✅ CORRECTO: Formulario HTML Simple

// app/hackathons/create/page.tsx
import { createHackathon } from '@/modules/hackathons/actions';

export default function CreateHackathonPage() {
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Crear Hackathon</h1>

<form action={createHackathon} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Nombre
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label htmlFor="slug" className="block text-sm font-medium mb-1">
Slug
</label>
<input
type="text"
id="slug"
name="slug"
required
className="w-full px-3 py-2 border rounded"
/>
</div>

<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Descripción
</label>
<textarea
id="description"
name="description"
required
rows={4}
className="w-full px-3 py-2 border rounded"
/>
</div>

<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Crear
</button>
</form>
</div>
);
}

Por qué es correcto:

  • HTML nativo
  • Server Action directo
  • Tailwind básico
  • Sin estado complejo
  • Sin validaciones del cliente (se hacen en el servidor)

⚡ Actualizaciones Optimistas (Patrón Estándar)

📋 Regla de Oro

SIEMPRE usar actualizaciones optimistas en lugar de window.location.reload() o recargas completas de página.

¿Qué son las Actualizaciones Optimistas?

Las actualizaciones optimistas actualizan el estado local de la UI inmediatamente antes de que el servidor confirme la acción. Esto proporciona:

  • Feedback instantáneo al usuario
  • Mejor experiencia de usuario (sin esperas)
  • Sin errores de "input stream" de Next.js
  • Sincronización suave con el servidor

Cuándo Usar

SÍ usar actualizaciones optimistas cuando:

  • ✅ Actualizas datos en una lista/tabla (cambiar rol, eliminar usuario, etc.)
  • ✅ Modificas estado que se muestra inmediatamente
  • ✅ La acción es reversible o tiene rollback claro

NO usar cuando:

  • ❌ La acción es crítica y no se puede revertir fácilmente
  • ❌ Necesitas confirmación del servidor antes de mostrar cambios
  • ❌ El estado depende de cálculos complejos del servidor

Patrón Estándar de Implementación

'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { myServerAction } from '@/modules/my-module/actions';

export default function MyComponent({ initialData }: { initialData: Data[] }) {
const router = useRouter();
// 1. Estado local para actualizaciones optimistas
const [data, setData] = useState<Data[]>(initialData);

// 2. Sincronizar con props cuando cambien (después de router.refresh())
useEffect(() => {
setData(initialData);
}, [initialData]);

async function handleUpdate(id: string, newValue: string) {
// 3. Guardar valor anterior para rollback si falla
const previousItem = data.find((item) => item.id === id);
const previousValue = previousItem?.value;

// 4. ACTUALIZACIÓN OPTIMISTA: Actualizar estado local inmediatamente
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: newValue } : item))
);

try {
// 5. Ejecutar Server Action
const result = await myServerAction(id, newValue);

if (!result.success) {
// 6. ROLLBACK: Revertir si falla
if (previousValue) {
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: previousValue } : item))
);
}
// Mostrar error
setError(result.error);
} else {
// 7. SINCRONIZAR: Actualizar con datos del servidor sin recargar
router.refresh(); // Esto actualizará initialData y el useEffect sincronizará
}
} catch (error) {
// 8. ROLLBACK en caso de error
if (previousValue) {
setData((prev) =>
prev.map((item) => (item.id === id ? { ...item, value: previousValue } : item))
);
}
setError(error.message);
}
}

return (
// Renderizar con data (estado local optimista)
<div>{/* ... */}</div>
);
}

Ejemplo Real: Actualizar Rol de Usuario

'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import { updateUserRole } from '@/modules/users/actions';
import { Role } from '@prisma/client';
import type { Profile } from '@prisma/client';

export default function UsersTable({ profiles: initialProfiles }: { profiles: Profile[] }) {
const router = useRouter();
const { user } = useUser();
const [profiles, setProfiles] = useState<Profile[]>(initialProfiles);

// Sincronizar con props
useEffect(() => {
setProfiles(initialProfiles);
}, [initialProfiles]);

async function handleRoleChange(profileId: string, newRole: Role) {
if (!user?.id) return;

// Guardar rol anterior
const previousProfile = profiles.find((p) => p.id === profileId);
const previousRole = previousProfile?.role;

// ACTUALIZACIÓN OPTIMISTA
setProfiles((prev) =>
prev.map((p) => (p.id === profileId ? { ...p, role: newRole } : p))
);

try {
const result = await updateUserRole(profileId, newRole, user.id);

if (!result.success) {
// ROLLBACK
if (previousRole) {
setProfiles((prev) =>
prev.map((p) => (p.id === profileId ? { ...p, role: previousRole } : p))
);
}
setError(result.error);
} else {
// SINCRONIZAR
router.refresh();
}
} catch (error) {
// ROLLBACK
if (previousRole) {
setProfiles((prev) =>
prev.map((p) => (p.id === profileId ? { ...p, role: previousRole } : p))
);
}
setError(error.message);
}
}

return (
<table>
{profiles.map((profile) => (
<tr key={profile.id}>
<td>{profile.name}</td>
<td>
<select
value={profile.role}
onChange={(e) => handleRoleChange(profile.id, e.target.value as Role)}
>
{/* ... */}
</select>
</td>
</tr>
))}
</table>
);
}

Ejemplo Real: Eliminar Usuario

async function handleDelete(profileId: string) {
if (!user?.id) return;

// Guardar perfil para rollback
const profileToDelete = profiles.find((p) => p.id === profileId);

// ACTUALIZACIÓN OPTIMISTA: Eliminar del estado local
setProfiles((prev) => prev.filter((p) => p.id !== profileId));

try {
const result = await deleteUser(profileId, user.id);

if (!result.success) {
// ROLLBACK: Restaurar perfil
if (profileToDelete) {
setProfiles((prev) => [...prev, profileToDelete].sort(/* ... */));
}
setError(result.error);
} else {
// SINCRONIZAR
router.refresh();
}
} catch (error) {
// ROLLBACK
if (profileToDelete) {
setProfiles((prev) => [...prev, profileToDelete].sort(/* ... */));
}
setError(error.message);
}
}

Checklist de Implementación

Al implementar actualizaciones optimistas, asegúrate de:

  • Estado local inicializado con props
  • useEffect para sincronizar con props cuando cambien
  • Guardar valor anterior antes de actualizar
  • Actualizar estado local inmediatamente (optimista)
  • Ejecutar Server Action
  • Rollback si falla (restaurar valor anterior)
  • router.refresh() si tiene éxito (sincronizar con servidor)
  • Manejo de errores con rollback

❌ NO Hacer

// ❌ MAL: Recargar toda la página
window.location.reload();

// ❌ MAL: No sincronizar con props
const [data, setData] = useState(initialData);
// Sin useEffect para sincronizar

// ❌ MAL: No hacer rollback si falla
setData(updatedData);
await serverAction();
// Si falla, el estado queda incorrecto

✅ Hacer

// ✅ BIEN: Actualización optimista con rollback
const [data, setData] = useState(initialData);
useEffect(() => setData(initialData), [initialData]);

const previous = data.find(...);
setData(updatedData); // Optimista
const result = await serverAction();
if (!result.success) {
setData(restorePrevious); // Rollback
} else {
router.refresh(); // Sincronizar
}

❌ INCORRECTO: Formulario Complejo

// ❌ NO HACER ESTO
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

export default function CreateHackathonPage() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(createHackathonSchema),
});

return (
<motion.form
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onSubmit={handleSubmit(onSubmit)}
>
<Input {...register('name')} error={errors.name?.message} />
{/* etc... */}
</motion.form>
);
}

Por qué está mal:

  • Demasiadas dependencias
  • Client Component innecesario
  • Validación duplicada (cliente + servidor)
  • Componentes personalizados complejos
  • Animaciones no requeridas

✅ CORRECTO: Tabla de Datos Simple

// app/admin/users/page.tsx
import { listProfiles } from '@/modules/users/queries';

export default async function UsersPage() {
const users = await listProfiles();

return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Usuarios</h1>

<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-100">
<th className="border p-2 text-left">Nombre</th>
<th className="border p-2 text-left">Email</th>
<th className="border p-2 text-left">Rol</th>
<th className="border p-2 text-left">Acciones</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="border p-2">{user.name}</td>
<td className="border p-2">{user.email}</td>
<td className="border p-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">
{user.role}
</span>
</td>
<td className="border p-2">
<RoleSelector userId={user.id} currentRole={user.role} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

Por qué es correcto:

  • Server Component (data fetching en servidor)
  • HTML table nativo
  • Tailwind básico para estilo
  • Solo un Client Component donde es necesario (RoleSelector)

✅ CORRECTO: Client Component Mínimo

// components/role-selector.tsx
'use client';
import { updateUserRole } from '@/modules/users/actions';
import { Role } from '@prisma/client';

export function RoleSelector({
userId,
currentRole
}: {
userId: string;
currentRole: Role;
}) {
async function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const newRole = e.target.value as Role;
await updateUserRole(userId, newRole);
}

return (
<select
defaultValue={currentRole}
onChange={handleChange}
className="px-2 py-1 border rounded"
>
<option value="PARTICIPANT">Participant</option>
<option value="JUDGE">Judge</option>
<option value="ORGANIZER">Organizer</option>
<option value="SPONSOR">Sponsor</option>
</select>
);
}

Por qué es correcto:

  • Client Component solo donde hay interactividad
  • HTML select nativo
  • Server Action para la lógica
  • Sin estado complejo

🎨 Tailwind: Solo lo Básico

Clases Permitidas

/* Layout */
max-w-{size}, mx-auto, p-{n}, space-y-{n}

/* Typography */
text-{size}, font-{weight}, text-{color}

/* Backgrounds */
bg-{color}

/* Borders */
border, rounded

/* Spacing */
m-{n}, p-{n}, gap-{n}

/* Display */
flex, grid, block, hidden

/* Hover States */
hover:bg-{color}

Clases NO Necesarias

/* Animaciones */
transition-*, animate-*, motion-*

/* Transformaciones */
transform, rotate-*, scale-*

/* Efectos */
shadow-*, blur-*, backdrop-*

/* Grid/Flexbox avanzado */
grid-cols-*, auto-cols-*, place-*

📋 Checklist por Página

Para cada página nueva, verificar:

Antes de Implementar

  • ¿Es absolutamente necesaria esta página?
  • ¿Puedo usar un Server Component?
  • ¿Necesito un Client Component? (probablemente no)

Durante Implementación

  • Usar HTML nativo (form, input, table, etc.)
  • Server Actions para mutations
  • Tailwind solo para layout básico
  • Sin librerías UI externas

Después de Implementar

  • ¿Funciona? ✅
  • ¿El core está bien probado? ✅
  • ¿Me preocupé por el diseño? ❌ (no debería)

🚫 Lista de NO Hacer

NO Instalar

# ❌ NO instalar estas librerías
pnpm add @radix-ui/react-*
pnpm add @headlessui/react
pnpm add framer-motion
pnpm add react-hot-toast
pnpm add sonner
pnpm add vaul
pnpm add cmdk

NO Crear

  • components/ui/button.tsx (con 50 variantes)
  • components/ui/input.tsx (wrapper de input)
  • components/ui/dialog.tsx (modal complejo)
  • lib/theme.ts (sistema de themes)
  • hooks/use-*.ts (hooks personalizados complejos)

NO Perder Tiempo En

  • ❌ Hacer que el dropdown sea "accesible"
  • ❌ Crear variantes de botones (primary, secondary, etc.)
  • ❌ Responsive design perfecto
  • ❌ Animaciones de loading
  • ❌ Toast notifications elaboradas
  • ❌ Modals con backdrop blur

✅ Lo Que SÍ Importa

Enfócate en Esto

  1. Server Actions funcionan correctamente

    // ✅ Esto es lo importante
    export async function createHackathon(formData: FormData) {
    const user = await getCurrentUser();
    requireRole(user, ['ORGANIZER']); // RBAC ✅

    const validated = createHackathonSchema.parse({...}); // Validación ✅

    await db.hackathon.create({ data: validated }); // DB ✅
    }
  2. Validaciones Zod exhaustivas

    // ✅ Esto es lo importante
    export const createHackathonSchema = z.object({
    name: z.string().min(5),
    slug: z.string().regex(/^[a-z0-9-]+$/),
    // ... validaciones completas
    });
  3. Tests completos

    // ✅ Esto es lo importante
    describe('createHackathon', () => {
    it('should create hackathon with valid data');
    it('should reject if not ORGANIZER');
    it('should validate slug format');
    });

📊 Distribución de Tiempo

Por Fase

Fase 0 (1 semana):
├── 3 días: Core (rbac.ts, errors.ts, db.ts)
├── 2 días: Users module + tests
├── 1 día: Onboarding form + admin table
└── 1 día: Integración y ajustes

Fase 1 (5-7 días):
├── 2 días: Hackathons schema + queries + actions
├── 2 días: Validaciones + tests
├── 1 día: CRUD forms (create, edit)
├── 1 día: List + detail pages
└── 1 día: Panel de organizador (tabla simple)

Nota: El frontend nunca debe tomar más del 20% del tiempo total.


🎯 Ejemplos de Páginas Completas

Landing Page (5 minutos)

// app/page.tsx
export default function HomePage() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">PuntoHack</h1>
<p className="text-gray-600 mb-8">Plataforma de gestión de hackathons</p>
<a
href="/sign-in"
className="bg-blue-600 text-white px-6 py-3 rounded"
>
Iniciar Sesión
</a>
</div>
</div>
);
}

Panel (10 minutos)

// app/dashboard/page.tsx
import { getCurrentUser } from '@/core/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) redirect('/sign-in');

// Redirección según rol
if (user.profile.role === 'ADMIN') redirect('/admin');
if (user.profile.role === 'ORGANIZER') redirect('/hackathons');
if (user.profile.role === 'JUDGE') redirect('/judge');

return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Panel</h1>
<p>Bienvenido, {user.profile.name}</p>
<p className="text-gray-600">Rol: {user.profile.role}</p>
</div>
);
}

🚀 Deployment

El frontend minimalista también facilita el deploy:

  • ✅ Build más rápido (menos dependencias)
  • ✅ Bundle más pequeño
  • ✅ Menos edge cases de UI
  • ✅ Menos bugs de frontend

📝 Resumen

Hacer ✅

  • Formularios HTML + Server Actions
  • Tablas nativas para datos
  • Tailwind básico (layout + colores)
  • Server Components por defecto
  • Client Components solo cuando es necesario

NO Hacer ❌

  • Instalar librerías UI complejas
  • Crear component library
  • Preocuparse por diseño
  • Añadir animaciones
  • Hacer responsive design elaborado

Mantra 🧘

"Si funciona y demuestra el core, es suficiente."


Última Actualización: 30 de diciembre, 2025
Filosofía: Core > UI
Tiempo en Frontend: Máximo 20% del total