project-mcp-server 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +335 -0
  3. package/dist/config.d.ts +15 -0
  4. package/dist/config.js +38 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +28 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/scanner/actions.d.ts +9 -0
  10. package/dist/scanner/actions.js +77 -0
  11. package/dist/scanner/actions.js.map +1 -0
  12. package/dist/scanner/cache.d.ts +4 -0
  13. package/dist/scanner/cache.js +34 -0
  14. package/dist/scanner/cache.js.map +1 -0
  15. package/dist/scanner/components.d.ts +17 -0
  16. package/dist/scanner/components.js +74 -0
  17. package/dist/scanner/components.js.map +1 -0
  18. package/dist/scanner/prisma.d.ts +17 -0
  19. package/dist/scanner/prisma.js +75 -0
  20. package/dist/scanner/prisma.js.map +1 -0
  21. package/dist/scanner/routes.d.ts +11 -0
  22. package/dist/scanner/routes.js +98 -0
  23. package/dist/scanner/routes.js.map +1 -0
  24. package/dist/setup.d.ts +10 -0
  25. package/dist/setup.js +289 -0
  26. package/dist/setup.js.map +1 -0
  27. package/dist/tools/env/index.d.ts +2 -0
  28. package/dist/tools/env/index.js +152 -0
  29. package/dist/tools/env/index.js.map +1 -0
  30. package/dist/tools/generate/index.d.ts +2 -0
  31. package/dist/tools/generate/index.js +320 -0
  32. package/dist/tools/generate/index.js.map +1 -0
  33. package/dist/tools/project/index.d.ts +2 -0
  34. package/dist/tools/project/index.js +193 -0
  35. package/dist/tools/project/index.js.map +1 -0
  36. package/package.json +35 -0
  37. package/skills/commit.md +109 -0
  38. package/skills/infra.md +218 -0
  39. package/skills/nextauth.md +256 -0
  40. package/skills/nextjs.md +262 -0
  41. package/skills/prisma.md +281 -0
  42. package/skills/project-intelligence.SKILL.md +141 -0
  43. package/skills/security.md +353 -0
  44. package/skills/shadcn.md +299 -0
  45. package/skills/testing.md +188 -0
  46. package/skills/zod.md +253 -0
@@ -0,0 +1,353 @@
1
+ ---
2
+ name: security
3
+ description: "Activar cuando se implementa autenticacion, se manejan inputs de usuario, se crean API endpoints, se trabaja con secrets o se prepara un deploy a produccion"
4
+ license: MIT
5
+ metadata:
6
+ version: "1.0.0"
7
+ ---
8
+
9
+ # Skill: Seguridad — OWASP para Next.js + Prisma
10
+
11
+ ## Cuándo cargar esta skill
12
+
13
+ - Implementar autenticación o autorización
14
+ - Manejar input de usuario o file uploads
15
+ - Crear API endpoints o Route Handlers
16
+ - Trabajar con secrets o credenciales
17
+ - Preparar deploy a producción
18
+ - Integrar APIs de terceros
19
+
20
+ ---
21
+
22
+ ## 1. Secrets Management
23
+
24
+ ```typescript
25
+ // ✗ NUNCA hardcodear secrets
26
+ const apiKey = "sk-proj-xxxxx"
27
+
28
+ // ✓ SIEMPRE usar variables de entorno
29
+ const apiKey = process.env.OPENAI_API_KEY
30
+ if (!apiKey) throw new Error("OPENAI_API_KEY no configurada")
31
+ ```
32
+
33
+ **Checklist:**
34
+ - No hardcodear API keys, tokens ni passwords
35
+ - Todos los secrets en variables de entorno
36
+ - `.env.local` en `.gitignore`
37
+ - No commitear secrets al historial de git
38
+ - Secrets de producción en la plataforma de hosting (Vercel, Railway)
39
+
40
+ ---
41
+
42
+ ## 2. Validación de Input
43
+
44
+ ### Con Zod (patrón estándar del proyecto)
45
+
46
+ ```typescript
47
+ import { z } from "zod"
48
+
49
+ const CreateUserSchema = z.object({
50
+ email: z.string().email({ message: "Email inválido" }),
51
+ name: z.string().min(1).max(100),
52
+ age: z.number().int().min(0).max(150)
53
+ })
54
+
55
+ // En Server Action
56
+ export async function createUser(formData: FormData) {
57
+ "use server"
58
+ const parsed = CreateUserSchema.safeParse({
59
+ email: formData.get("email"),
60
+ name: formData.get("name"),
61
+ age: Number(formData.get("age"))
62
+ })
63
+ if (!parsed.success) {
64
+ return { error: parsed.error.flatten().fieldErrors }
65
+ }
66
+ // Continuar con datos validados
67
+ await prisma.user.create({ data: parsed.data })
68
+ }
69
+ ```
70
+
71
+ ### File Uploads
72
+
73
+ ```typescript
74
+ function validateFileUpload(file: File) {
75
+ const maxSize = 5 * 1024 * 1024 // 5MB
76
+ if (file.size > maxSize) throw new Error("Archivo muy grande (máx 5MB)")
77
+
78
+ const allowedTypes = ["image/jpeg", "image/png", "image/webp"]
79
+ if (!allowedTypes.includes(file.type)) throw new Error("Tipo de archivo no permitido")
80
+
81
+ const ext = file.name.toLowerCase().match(/\.[^.]+$/)?.[0]
82
+ const allowedExts = [".jpg", ".jpeg", ".png", ".webp"]
83
+ if (!ext || !allowedExts.includes(ext)) throw new Error("Extensión no permitida")
84
+ }
85
+ ```
86
+
87
+ **Checklist:**
88
+ - Todos los inputs validados con Zod antes de procesarlos
89
+ - File uploads restringidos (tamaño, tipo, extensión)
90
+ - No usar input de usuario directo en queries
91
+ - Validar con whitelist (no blacklist)
92
+ - Mensajes de error sin información sensible
93
+
94
+ ---
95
+
96
+ ## 3. SQL Injection — Prisma
97
+
98
+ Prisma previene SQL injection por defecto con queries parametrizadas. El riesgo está en `$queryRaw`:
99
+
100
+ ```typescript
101
+ // ✗ NUNCA concatenar strings en raw queries
102
+ const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = '${email}'`
103
+
104
+ // ✓ SIEMPRE usar tagged template de Prisma (parametrizado)
105
+ const users = await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`
106
+
107
+ // ✓ MEJOR: usar el query builder de Prisma
108
+ const users = await prisma.user.findMany({
109
+ where: { email },
110
+ select: { id: true, email: true, name: true } // select explícito
111
+ })
112
+ ```
113
+
114
+ **Checklist:**
115
+ - Usar el query builder de Prisma (no raw queries salvo necesidad)
116
+ - Si se usa `$queryRaw`, usar tagged template literals (no concatenación)
117
+ - Select explícito — nunca devolver todos los campos sin filtrar
118
+
119
+ ---
120
+
121
+ ## 4. Autenticación y Autorización
122
+
123
+ ### Tokens en httpOnly cookies (no localStorage)
124
+
125
+ ```typescript
126
+ // ✗ localStorage es vulnerable a XSS
127
+ localStorage.setItem("token", token)
128
+
129
+ // ✓ httpOnly cookies
130
+ cookies().set("session", token, {
131
+ httpOnly: true,
132
+ secure: true,
133
+ sameSite: "strict",
134
+ maxAge: 60 * 60 // 1 hora
135
+ })
136
+ ```
137
+
138
+ ### Verificar autorización antes de operar
139
+
140
+ ```typescript
141
+ export async function deleteUser(userId: string) {
142
+ "use server"
143
+ const session = await auth()
144
+ if (!session?.user) throw new Error("No autenticado")
145
+
146
+ // Verificar permisos
147
+ if (!hasPermission(session.user, "users:delete")) {
148
+ throw new Error("Sin permisos")
149
+ }
150
+
151
+ await prisma.user.delete({ where: { id: userId } })
152
+ revalidateTag("users")
153
+ }
154
+ ```
155
+
156
+ **Checklist:**
157
+ - Tokens en httpOnly cookies, nunca en localStorage
158
+ - Verificar auth en cada Server Action y Route Handler
159
+ - RBAC implementado con `hasPermission()`
160
+ - Sesiones con expiración configurada
161
+
162
+ ---
163
+
164
+ ## 5. XSS Prevention
165
+
166
+ ### Sanitizar HTML de usuario
167
+
168
+ ```typescript
169
+ import DOMPurify from "isomorphic-dompurify"
170
+
171
+ function UserContent({ html }: { html: string }) {
172
+ const clean = DOMPurify.sanitize(html, {
173
+ ALLOWED_TAGS: ["b", "i", "em", "strong", "p", "br", "ul", "li"],
174
+ ALLOWED_ATTR: []
175
+ })
176
+ return <div dangerouslySetInnerHTML={{ __html: clean }} />
177
+ }
178
+ ```
179
+
180
+ ### Content Security Policy en Next.js
181
+
182
+ ```typescript
183
+ // next.config.ts
184
+ const securityHeaders = [
185
+ {
186
+ key: "Content-Security-Policy",
187
+ value: [
188
+ "default-src 'self'",
189
+ "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
190
+ "style-src 'self' 'unsafe-inline'",
191
+ "img-src 'self' data: https:",
192
+ "font-src 'self'",
193
+ "connect-src 'self' https://api.tudominio.com"
194
+ ].join("; ")
195
+ },
196
+ { key: "X-Frame-Options", value: "DENY" },
197
+ { key: "X-Content-Type-Options", value: "nosniff" },
198
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }
199
+ ]
200
+ ```
201
+
202
+ **Checklist:**
203
+ - HTML de usuario sanitizado con DOMPurify
204
+ - CSP headers configurados
205
+ - X-Frame-Options, X-Content-Type-Options configurados
206
+ - No renderizar contenido dinámico sin sanitizar
207
+
208
+ ---
209
+
210
+ ## 6. CSRF Protection
211
+
212
+ Next.js Server Actions incluyen protección CSRF por defecto (Origin header check). Para Route Handlers custom:
213
+
214
+ ```typescript
215
+ export async function POST(request: Request) {
216
+ const origin = request.headers.get("origin")
217
+ const allowedOrigins = [process.env.NEXT_PUBLIC_APP_URL]
218
+
219
+ if (!origin || !allowedOrigins.includes(origin)) {
220
+ return Response.json({ error: "Forbidden" }, { status: 403 })
221
+ }
222
+
223
+ // Procesar request
224
+ }
225
+ ```
226
+
227
+ **Checklist:**
228
+ - Server Actions usan CSRF nativo de Next.js
229
+ - Route Handlers validan Origin header
230
+ - Cookies con `SameSite=Strict`
231
+
232
+ ---
233
+
234
+ ## 7. Rate Limiting
235
+
236
+ ### En Route Handlers con headers
237
+
238
+ ```typescript
239
+ const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
240
+
241
+ function rateLimit(ip: string, limit = 100, windowMs = 15 * 60 * 1000): boolean {
242
+ const now = Date.now()
243
+ const record = rateLimitMap.get(ip)
244
+
245
+ if (!record || now > record.resetTime) {
246
+ rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs })
247
+ return true
248
+ }
249
+
250
+ record.count++
251
+ return record.count <= limit
252
+ }
253
+
254
+ export async function POST(request: Request) {
255
+ const ip = request.headers.get("x-forwarded-for") ?? "unknown"
256
+
257
+ if (!rateLimit(ip, 10, 60_000)) { // 10 req/min
258
+ return Response.json({ error: "Too many requests" }, { status: 429 })
259
+ }
260
+
261
+ // Procesar request
262
+ }
263
+ ```
264
+
265
+ **Checklist:**
266
+ - Rate limiting en todos los API endpoints
267
+ - Límites más estrictos en operaciones costosas (búsquedas, auth)
268
+ - Rate limiting por IP y por usuario autenticado
269
+
270
+ ---
271
+
272
+ ## 8. Logging Seguro
273
+
274
+ ```typescript
275
+ // ✗ NUNCA loguear datos sensibles
276
+ console.log("Login:", { email, password })
277
+ console.log("Payment:", { cardNumber, cvv })
278
+
279
+ // ✓ Redactar datos sensibles
280
+ console.log("Login:", { email, userId })
281
+ console.log("Payment:", { last4: card.last4, userId })
282
+ ```
283
+
284
+ ### Errores al usuario vs servidor
285
+
286
+ ```typescript
287
+ // ✗ NUNCA exponer detalles internos
288
+ catch (error) {
289
+ return Response.json({ error: error.message, stack: error.stack }, { status: 500 })
290
+ }
291
+
292
+ // ✓ Mensajes genéricos al usuario, detalle en server logs
293
+ catch (error) {
294
+ console.error("Error interno:", error)
295
+ return Response.json({ error: "Ocurrió un error. Intentá de nuevo." }, { status: 500 })
296
+ }
297
+ ```
298
+
299
+ **Checklist:**
300
+ - No loguear passwords, tokens ni secrets
301
+ - Errores genéricos para el usuario
302
+ - Errores detallados solo en server logs
303
+ - No exponer stack traces al usuario
304
+
305
+ ---
306
+
307
+ ## 9. Dependencias
308
+
309
+ ```bash
310
+ # Verificar vulnerabilidades
311
+ pnpm audit
312
+
313
+ # Actualizar dependencias
314
+ pnpm update
315
+
316
+ # Ver paquetes desactualizados
317
+ pnpm outdated
318
+ ```
319
+
320
+ **Checklist:**
321
+ - Dependencias actualizadas
322
+ - Sin vulnerabilidades conocidas (`pnpm audit` limpio)
323
+ - Lock file commiteado
324
+ - Dependabot o Renovate habilitado en GitHub
325
+
326
+ ---
327
+
328
+ ## Checklist Pre-Deploy
329
+
330
+ Antes de CUALQUIER deploy a producción:
331
+
332
+ - [ ] **Secrets**: No hardcodeados, todos en env vars
333
+ - [ ] **Input Validation**: Todos los inputs validados con Zod
334
+ - [ ] **SQL Injection**: Queries parametrizadas (Prisma builder o tagged template)
335
+ - [ ] **XSS**: Contenido de usuario sanitizado
336
+ - [ ] **CSRF**: Protección habilitada (Server Actions + Origin check)
337
+ - [ ] **Auth**: Tokens en httpOnly cookies, RBAC verificado
338
+ - [ ] **Rate Limiting**: Habilitado en todos los endpoints
339
+ - [ ] **HTTPS**: Forzado en producción
340
+ - [ ] **Security Headers**: CSP, X-Frame-Options configurados
341
+ - [ ] **Error Handling**: Sin datos sensibles en errores
342
+ - [ ] **Logging**: Sin datos sensibles en logs
343
+ - [ ] **Dependencias**: Actualizadas, sin vulnerabilidades
344
+ - [ ] **CORS**: Configurado correctamente
345
+ - [ ] **File Uploads**: Validados (tamaño, tipo)
346
+
347
+ ---
348
+
349
+ ## Referencias
350
+
351
+ - [OWASP Top 10](https://owasp.org/www-project-top-ten/)
352
+ - [Next.js Security](https://nextjs.org/docs/app/building-your-application/configuring/security-headers)
353
+ - [Prisma Security Best Practices](https://www.prisma.io/docs/orm/prisma-client/queries/raw-database-access)
@@ -0,0 +1,299 @@
1
+ ---
2
+ name: shadcn
3
+ description: "Activar cuando se crean o modifican componentes de UI, se agregan componentes de shadcn o se trabaja con Tailwind CSS"
4
+ license: MIT
5
+ metadata:
6
+ version: "1.0.0"
7
+ ---
8
+
9
+ # Skill: shadcn/ui + Tailwind CSS 4
10
+
11
+ ## Cuándo cargar esta skill
12
+ - Crear o modificar componentes de UI
13
+ - Agregar nuevos componentes de shadcn
14
+ - Trabajar con Tailwind CSS 4
15
+ - Implementar dark/light mode
16
+ - Usar Radix UI primitivos directamente
17
+
18
+ ---
19
+
20
+ ## Tailwind CSS 4 — diferencias clave con v3
21
+
22
+ ```css
23
+ /* ✓ Tailwind 4: importar en CSS, no usar tailwind.config.js */
24
+ @import "tailwindcss";
25
+
26
+ /* Definir design tokens como variables CSS */
27
+ @theme {
28
+ --color-primary: oklch(0.5 0.2 260);
29
+ --color-primary-foreground: oklch(0.99 0 0);
30
+ --font-sans: "Inter", sans-serif;
31
+ --radius: 0.5rem;
32
+ }
33
+ ```
34
+
35
+ ```typescript
36
+ // ✗ Tailwind 4: ya no existe tailwind.config.js como antes
37
+ // ✓ Toda la configuración va en globals.css con @theme
38
+
39
+ // Las clases siguen siendo las mismas en templates
40
+ <div className="bg-primary text-primary-foreground rounded-lg p-4">
41
+ ```
42
+
43
+ ### Variables CSS de shadcn en Tailwind 4
44
+ ```css
45
+ /* globals.css */
46
+ @import "tailwindcss";
47
+
48
+ @theme {
49
+ --background: 0 0% 100%;
50
+ --foreground: 240 10% 3.9%;
51
+ --card: 0 0% 100%;
52
+ --card-foreground: 240 10% 3.9%;
53
+ --primary: 240 5.9% 10%;
54
+ --primary-foreground: 0 0% 98%;
55
+ --muted: 240 4.8% 95.9%;
56
+ --muted-foreground: 240 3.8% 46.1%;
57
+ --border: 240 5.9% 90%;
58
+ --radius: 0.5rem;
59
+ }
60
+
61
+ .dark {
62
+ --background: 240 10% 3.9%;
63
+ --foreground: 0 0% 98%;
64
+ /* ... resto de dark mode vars */
65
+ }
66
+ ```
67
+
68
+ ---
69
+
70
+ ## shadcn/ui — agregar componentes
71
+
72
+ ```bash
73
+ # Agregar un componente nuevo (siempre con pnpm dlx)
74
+ pnpm dlx shadcn@latest add button
75
+ pnpm dlx shadcn@latest add card dialog form input table
76
+ pnpm dlx shadcn@latest add data-table # con columnas configurables
77
+
78
+ # Ver todos los disponibles
79
+ pnpm dlx shadcn@latest add --list
80
+ ```
81
+
82
+ Los componentes se generan en `src/components/ui/`. **No modificar** esos archivos
83
+ directamente — crear wrappers en `src/components/[feature]/`.
84
+
85
+ ---
86
+
87
+ ## Patrones de componentes
88
+
89
+ ### Componente con CVA (variantes)
90
+ ```typescript
91
+ // components/ui/badge.tsx — patrón típico de shadcn
92
+ import { cva, type VariantProps } from "class-variance-authority"
93
+ import { cn } from "@/lib/utils"
94
+
95
+ const badgeVariants = cva(
96
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
97
+ {
98
+ variants: {
99
+ variant: {
100
+ default: "border-transparent bg-primary text-primary-foreground",
101
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
102
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
103
+ outline: "text-foreground"
104
+ }
105
+ },
106
+ defaultVariants: { variant: "default" }
107
+ }
108
+ )
109
+
110
+ interface BadgeProps
111
+ extends React.HTMLAttributes<HTMLDivElement>,
112
+ VariantProps<typeof badgeVariants> {}
113
+
114
+ export function Badge({ className, variant, ...props }: BadgeProps) {
115
+ return <div className={cn(badgeVariants({ variant }), className)} {...props} />
116
+ }
117
+ ```
118
+
119
+ ### Componente wrapper de shadcn (extensión)
120
+ ```typescript
121
+ // components/users/user-card.tsx — wrapper, no modifica el original
122
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
123
+ import { Badge } from "@/components/ui/badge"
124
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
125
+ import { cn } from "@/lib/utils"
126
+
127
+ interface UserCardProps {
128
+ user: { name: string; email: string; role: string; avatar?: string }
129
+ className?: string
130
+ }
131
+
132
+ export function UserCard({ user, className }: UserCardProps) {
133
+ return (
134
+ <Card className={cn("hover:shadow-md transition-shadow", className)}>
135
+ <CardHeader className="flex flex-row items-center gap-3 space-y-0">
136
+ <Avatar>
137
+ <AvatarImage src={user.avatar} />
138
+ <AvatarFallback>{user.name.slice(0, 2).toUpperCase()}</AvatarFallback>
139
+ </Avatar>
140
+ <div>
141
+ <CardTitle className="text-base">{user.name}</CardTitle>
142
+ <p className="text-sm text-muted-foreground">{user.email}</p>
143
+ </div>
144
+ <Badge variant="secondary" className="ml-auto">{user.role}</Badge>
145
+ </CardHeader>
146
+ </Card>
147
+ )
148
+ }
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Forms con shadcn/ui + React Hook Form + Zod
154
+
155
+ ```typescript
156
+ "use client"
157
+ import { useForm } from "react-hook-form"
158
+ import { zodResolver } from "@hookform/resolvers/zod"
159
+ import { z } from "zod"
160
+ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
161
+ import { Input } from "@/components/ui/input"
162
+ import { Button } from "@/components/ui/button"
163
+ import { useActionState } from "react"
164
+ import { createUser } from "@/server/actions/users"
165
+
166
+ const FormSchema = z.object({
167
+ name: z.string().min(2, "Mínimo 2 caracteres"),
168
+ email: z.string().email("Email inválido")
169
+ })
170
+
171
+ export function CreateUserForm() {
172
+ const form = useForm<z.infer<typeof FormSchema>>({
173
+ resolver: zodResolver(FormSchema),
174
+ defaultValues: { name: "", email: "" }
175
+ })
176
+
177
+ const [state, action, isPending] = useActionState(createUser, null)
178
+
179
+ return (
180
+ <Form {...form}>
181
+ <form action={action} className="space-y-4">
182
+ <FormField
183
+ control={form.control}
184
+ name="name"
185
+ render={({ field }) => (
186
+ <FormItem>
187
+ <FormLabel>Nombre</FormLabel>
188
+ <FormControl>
189
+ <Input placeholder="Alan García" {...field} />
190
+ </FormControl>
191
+ <FormMessage />
192
+ </FormItem>
193
+ )}
194
+ />
195
+ <FormField
196
+ control={form.control}
197
+ name="email"
198
+ render={({ field }) => (
199
+ <FormItem>
200
+ <FormLabel>Email</FormLabel>
201
+ <FormControl>
202
+ <Input type="email" {...field} />
203
+ </FormControl>
204
+ <FormMessage />
205
+ </FormItem>
206
+ )}
207
+ />
208
+ <Button type="submit" disabled={isPending}>
209
+ {isPending ? "Creando..." : "Crear usuario"}
210
+ </Button>
211
+ </form>
212
+ </Form>
213
+ )
214
+ }
215
+ ```
216
+
217
+ ---
218
+
219
+ ## Dark mode con next-themes
220
+
221
+ ```typescript
222
+ // app/layout.tsx
223
+ import { ThemeProvider } from "next-themes"
224
+
225
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
226
+ return (
227
+ <html lang="es" suppressHydrationWarning>
228
+ <body>
229
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
230
+ {children}
231
+ </ThemeProvider>
232
+ </body>
233
+ </html>
234
+ )
235
+ }
236
+
237
+ // components/theme-toggle.tsx
238
+ "use client"
239
+ import { useTheme } from "next-themes"
240
+ import { Button } from "@/components/ui/button"
241
+ import { Moon, Sun } from "lucide-react"
242
+
243
+ export function ThemeToggle() {
244
+ const { theme, setTheme } = useTheme()
245
+
246
+ return (
247
+ <Button
248
+ variant="ghost"
249
+ size="icon"
250
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
251
+ >
252
+ <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
253
+ <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
254
+ </Button>
255
+ )
256
+ }
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Toasts con Sonner
262
+
263
+ ```typescript
264
+ // app/layout.tsx — agregar el Toaster
265
+ import { Toaster } from "sonner"
266
+
267
+ // En el layout
268
+ <Toaster richColors position="bottom-right" />
269
+
270
+ // Uso en Client Components
271
+ import { toast } from "sonner"
272
+
273
+ // Éxito, error, promesa
274
+ toast.success("Usuario creado")
275
+ toast.error("Error al guardar")
276
+ toast.promise(saveUser(), {
277
+ loading: "Guardando...",
278
+ success: "Guardado",
279
+ error: "Error al guardar"
280
+ })
281
+ ```
282
+
283
+ ---
284
+
285
+ ## cn() — utilitaria de clases
286
+
287
+ ```typescript
288
+ // lib/utils.ts
289
+ import { clsx, type ClassValue } from "clsx"
290
+ import { twMerge } from "tailwind-merge"
291
+
292
+ export function cn(...inputs: ClassValue[]) {
293
+ return twMerge(clsx(inputs))
294
+ }
295
+
296
+ // Uso: merge condicional sin conflictos de Tailwind
297
+ cn("px-4 py-2", isActive && "bg-primary", className)
298
+ // ✓ Resuelve conflictos: cn("p-4", "px-2") → "p-4 px-2" (px-2 gana)
299
+ ```