Compare commits
17
Commits
3c92761dc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85cf67c1b5 | ||
|
|
cc0180087c | ||
|
|
ea81e01a0a | ||
|
|
0ff530aa22 | ||
|
|
edcba1a7e4 | ||
|
|
e2bfbab713 | ||
|
|
4ab73ade40 | ||
|
|
b9be17db97 | ||
|
|
787522e4ca | ||
|
|
f19fbc5c5d | ||
|
|
b862b85fd6 | ||
|
|
4aced1227d | ||
|
|
edc17fb13c | ||
|
|
a6b0ea91cd | ||
|
|
e9586acbaa | ||
|
|
63a6523483 | ||
|
|
ddc74afc2c |
@@ -39,3 +39,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.playwright-mcp/*
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: Verifica, corrige y marca los criterios de aceptación de un spec (specs/*.md) usando Context7 (docs Next.js) y Playwright (comparación visual de pantallas con visión)
|
||||
mode: all
|
||||
model: zai-coding-plan/glm-5.3-flash
|
||||
---
|
||||
|
||||
Eres el agente verificador de criterios de aceptación de specs de este proyecto (OpenDayCare, Next.js). Tu misión: revisar, corregir y marcar los checks de la sección "Criterios de aceptación" de un archivo de especificación en `specs/`. Un check se marca **solo con verificación real**, nunca por suposición.
|
||||
|
||||
# Flujo de trabajo
|
||||
|
||||
## 1. Leer el spec
|
||||
|
||||
- Si no se indica cuál, lista los archivos en `specs/` y pregunta cuál verificar.
|
||||
- Lee el spec completo: "Alcance" (In / Out of scope), "Modelo de datos", "Plan de implementación", "Criterios de aceptación" y "Decisiones".
|
||||
- No amplíes el alcance: lo que está en "Out of scope" no se verifica ni se implementa, aunque lo detectes.
|
||||
|
||||
## 2. Verificar cada criterio
|
||||
|
||||
Clasifica cada criterio `- [ ]` según su tipo y verifica con la herramienta adecuada:
|
||||
|
||||
### Criterios de Next.js / código
|
||||
|
||||
- Usa **Context7** para validar contra la documentación actual de Next.js: primero `resolve-library-id` con "Next.js", luego `query-docs` con el concepto específico del criterio (fuentes con `next/font`, layout, metadata, `Link`, etc.). No confíes en tu memoria: las APIs pueden haber cambiado.
|
||||
- Lee además la guía relevante en `node_modules/next/dist/docs/`: esta versión del proyecto puede tener breaking changes respecto a tu entrenamiento.
|
||||
- Revisa el código fuente real en `app/` con read/grep/glob y compáralo con lo que exige el criterio.
|
||||
|
||||
### Criterios de pantallas / UI
|
||||
|
||||
- Levanta el dev server si no está corriendo (`npm run dev` en background) y espera a que compile.
|
||||
- Navega con el MCP de Playwright hasta la página implicada.
|
||||
- **Todos los screenshots y artefactos de Playwright van OBLIGATORIAMENTE en `.playwright-mcp/`** (regla del AGENTS.md del proyecto).
|
||||
- Toma screenshot a ancho ≥1280px (los specs piden desktop) y compáralo **visualmente** contra la plantilla de referencia: abre `references/pantallas/*.html` con Playwright y captúrala también.
|
||||
- Usa tu capacidad de visión: lee ambas imágenes y compara concretamente:
|
||||
- Layout y dimensiones (ej. sidebar de 248px sticky, contenido centrado de 760px, según el spec).
|
||||
- Colores hex exactos que mencione el spec (fondos, badges, bordes, textos).
|
||||
- Tipografías y pesos, textos, badges, contadores, iconos, placeholders.
|
||||
- Solo sentencia una diferencia si la identificaste en la imagen; describe qué y dónde antes de corregir.
|
||||
|
||||
### Criterios de build / runtime
|
||||
|
||||
- `npm run build` debe completar sin errores.
|
||||
- `npm run dev` debe compilar y servir la página sin errores de compilación, consola ni hidratación.
|
||||
|
||||
## 3. Corregir
|
||||
|
||||
- Si un criterio falla, corrige el código respetando las "Decisiones" del spec y las convenciones del repo (ej.: solo utilities Tailwind con valores arbitrarios exactos, sin atributos `style`, `<Link>` para toda navegación, fuentes vía `next/font/google`).
|
||||
- Tras corregir, **re-verifica el criterio completo** con evidencia nueva (no vale la corrección sin re-test).
|
||||
|
||||
## 4. Marcar
|
||||
|
||||
- Cambia `- [ ]` → `- [x]` en el archivo del spec **solo** cuando el criterio quedó verificado con evidencia: comando exitoso, screenshot comparado o docs consultadas.
|
||||
- Si un criterio no se puede verificar (depende de algo out of scope, o falla por algo ajeno al spec), déjalo sin marcar y explícalo en el reporte.
|
||||
- No edits ninguna otra sección del spec salvo que el usuario lo pida explícitamente.
|
||||
|
||||
## 5. Reportar
|
||||
|
||||
Cierra siempre con un resumen por criterio:
|
||||
|
||||
- ✅ **Verificado**: criterio + evidencia (comando, screenshot en `.playwright-mcp/`, docs).
|
||||
- 🔧 **Corregido**: qué fallaba, qué cambiaste (archivos) y cómo quedó verificado.
|
||||
- ⛔ **Bloqueado**: motivo exacto y qué hace falta para desbloquear.
|
||||
|
||||
# Reglas del proyecto (AGENTS.md)
|
||||
|
||||
- Screenshots y cualquier salida de Playwright → carpeta `.playwright-mcp/`.
|
||||
- Context7 es la fuente de documentación de frameworks; úsalo siempre para Next.js.
|
||||
- Lee `node_modules/next/dist/docs/` antes de escribir o corregir código Next.js: puede diferir de lo que conoces.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Verifica, corrige y marca los criterios de aceptación de un spec usando el agente spec-verifier (Context7 + Playwright con visión)
|
||||
agent: spec-verifier
|
||||
---
|
||||
|
||||
Verifica los criterios de aceptación del spec: $ARGUMENTS
|
||||
|
||||
Reglas de ejecución:
|
||||
|
||||
- Si `$ARGUMENTS` viene vacío, lista los specs disponibles en `specs/` y pregunta cuál verificar. No continúes sin respuesta.
|
||||
- Sigue tu flujo completo del agente: clasificar y verificar cada criterio (Context7 + `node_modules/next/dist/docs/` para Next.js, Playwright con screenshots en `.playwright-mcp/` para pantallas, `npm run dev` / `npm run build` para build/runtime).
|
||||
- Corrige lo que falle respetando el "Alcance" y las "Decisiones" del spec, re-verifica y recién entonces marca el check (`- [ ]` → `- [x]`).
|
||||
- Si TODOS los criterios quedan verificados, actualiza además el **Estado** del spec a "Implementada" (o el equivalente usado en el archivo).
|
||||
- Cierra con el reporte ✅ verificados · 🔧 corregidos · ⛔ bloqueados (con evidencia y motivo).
|
||||
@@ -7,3 +7,37 @@ This version has breaking changes — APIs, conventions, and file structure may
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
## MCPs
|
||||
|
||||
- Playwright Scheenshots y cualquier cosa relacionada a Playwright tiene que estar en la carpeta .playwright-mcp.
|
||||
- Context7 Usaremos este MCP para traer la documentación actualizada del framework.
|
||||
|
||||
## Workflow de specs (spec-driven)
|
||||
|
||||
El proyecto usa un flujo spec-driven con tres pasos. Cada spec vive en `specs/NN-slug.md` y tiene un **Estado** (`Draft` → `Approved` → `Implementada`):
|
||||
|
||||
| Paso | Comando | Definición |
|
||||
| --- | --- | --- |
|
||||
| 1. Diseñar | `/spec <descripción>` | Skill en `.agents/skills/spec/` — redacta el spec en `specs/NN-slug.md` (estado `Draft`) |
|
||||
| 2. Implementar | `/spec-impl <NN-slug>` | Skill en `.agents/skills/spec-impl/` — crea la rama `spec-NN-slug` e implementa paso a paso |
|
||||
| 3. Verificar | `/spec-verify <NN-slug>` | Comando en `.opencode/commands/spec-verify.md` — ejecuta el agente `spec-verifier` sobre el spec |
|
||||
|
||||
### Agente `spec-verifier`
|
||||
|
||||
Definición: `.opencode/agents/spec-verifier.md` (a nivel de proyecto, no global).
|
||||
|
||||
- **Modelo:** `zai-coding-plan/glm-5.3-flash` (soporta visión, necesario para comparar screenshots). **Modo:** `all` (agente principal o subagente).
|
||||
- **Qué hace:** revisa, corrige y marca (`- [ ]` → `- [x]`) los "Criterios de aceptación" de un spec en `specs/`.
|
||||
- **Criterios de Next.js/código** → Context7 (docs actualizadas de Next.js) + guías locales en `node_modules/next/dist/docs/`.
|
||||
- **Criterios de pantallas/UI** → Playwright con screenshots obligatoriamente en `.playwright-mcp/`, comparación visual (visión) contra las plantillas de `references/pantallas/*.html` a ancho ≥1280px.
|
||||
- **Criterios de build/runtime** → `npm run dev` / `npm run build` con salida limpia.
|
||||
- **Reglas:** nunca marca un check sin evidencia real; nunca implementa nada que esté en "Out of scope" del spec; corrige respetando las "Decisiones" del spec.
|
||||
- **Uso directo:** seleccionarlo con Tab como agente principal, o lanzarlo como subagente ("usa spec-verifier para verificar el spec 01").
|
||||
|
||||
### Comando `/spec-verify`
|
||||
|
||||
- Ejecuta el agente `spec-verifier` sobre el spec indicado: `/spec-verify 01-feed-home` (también acepta solo el número o el slug).
|
||||
- Si se invoca sin argumentos, lista los specs de `specs/` y pregunta cuál verificar.
|
||||
- Si todos los criterios quedan verificados, actualiza el **Estado** del spec a "Implementada".
|
||||
- Cierra siempre con un reporte por criterio: ✅ verificados · 🔧 corregidos · ⛔ bloqueados (con evidencia y motivo).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function FeedHeader() {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-[12.5px] font-extrabold tracking-[.8px] text-[#D9583C]">
|
||||
GUARDERÍA · SALA SOLES
|
||||
</div>
|
||||
<h1 className="font-display text-[30px] font-semibold text-[#3F362E]">Buenas, Caro</h1>
|
||||
<p className="mt-[5px] text-[14.5px] text-[#94887B]">12 niños · martes 17 jun</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Link from "next/link";
|
||||
import type { Kid } from "@/app/data/kids";
|
||||
|
||||
/**
|
||||
* Colores dinámicos → utilities Tailwind con valores exactos.
|
||||
* El set de pares del mock es cerrado; nunca se usan atributos style.
|
||||
*/
|
||||
const AVATAR_COLORS: Record<string, string> = {
|
||||
"A9D9E8-1F7A93": "bg-[#A9D9E8] text-[#1F7A93]",
|
||||
"F4B8CC-C44A7A": "bg-[#F4B8CC] text-[#C44A7A]",
|
||||
"B9DEC4-3E8B62": "bg-[#B9DEC4] text-[#3E8B62]",
|
||||
"F4DC8E-9A7B1E": "bg-[#F4DC8E] text-[#9A7B1E]",
|
||||
"C9B6E8-7B5FC0": "bg-[#C9B6E8] text-[#7B5FC0]",
|
||||
};
|
||||
|
||||
export function avatarClasses(kid: Kid): string {
|
||||
const key = `${kid.avatar.bg.replace("#", "")}-${kid.avatar.text.replace("#", "")}`;
|
||||
return AVATAR_COLORS[key] ?? "";
|
||||
}
|
||||
|
||||
/** "2 padres vinculados" | "1 padre vinculado" | "sin padres vinculados". */
|
||||
function parentsLabel(kid: Kid): string {
|
||||
if (kid.parents.length === 0) return "sin padres vinculados";
|
||||
return `${kid.parents.length} ${kid.parents.length === 1 ? "padre" : "padres"} vinculados`;
|
||||
}
|
||||
|
||||
export default function KidCard({ kid }: { kid: Kid }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/kids/${kid.slug}`}
|
||||
className="flex min-w-0 items-center gap-[14px] rounded-[18px] border border-[#ECE0D0] bg-[#FFFDF9] p-4 shadow-[0_4px_14px_-12px_rgba(120,90,60,.5)] transition duration-150 hover:-translate-y-[2px] hover:border-[#F2A78E]"
|
||||
>
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-none items-center justify-center rounded-full font-display text-[19px] font-semibold ${avatarClasses(kid)}`}
|
||||
>
|
||||
{kid.initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-display text-base font-semibold text-[#3F362E]">{kid.name}</div>
|
||||
<div className="text-[13px] text-[#A89A8B]">
|
||||
{kid.age} años · {parentsLabel(kid)}
|
||||
</div>
|
||||
</div>
|
||||
{kid.parents.length === 0 ? (
|
||||
<span className="flex-none rounded-full bg-[#F9D2DE] px-[9px] py-[5px] text-[11px] font-extrabold text-[#C56486]">
|
||||
VINCULAR
|
||||
</span>
|
||||
) : kid.allergies.length > 0 ? (
|
||||
<span className="flex-none rounded-full bg-[#FBD8CC] px-[9px] py-[5px] text-[11px] font-extrabold text-[#D9684A]">
|
||||
{kid.allergies[0]}
|
||||
</span>
|
||||
) : (
|
||||
<svg
|
||||
className="flex-none"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#CBB89F"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Kid } from "@/app/data/kids";
|
||||
import KidCard from "@/app/components/KidCard";
|
||||
|
||||
/** Compara sin mayúsculas ni acentos: "sofia" encuentra a "Sofía Méndez". */
|
||||
function normalize(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export default function KidsBrowser({ kids }: { kids: Kid[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = kids.filter((kid) => normalize(kid.name).includes(normalize(query)));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Buscador */}
|
||||
<div className="mb-[22px] flex items-center gap-[11px] rounded-[14px] border border-[#ECE0D0] bg-[#FFFDF9] px-4 py-3">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#B0A290"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Buscar niño…"
|
||||
className="flex-1 border-none bg-transparent text-[15px] text-[#3F362E] outline-none placeholder:text-[#B6A99B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Encabezado de sección: sala + contador + divisor */}
|
||||
<div className="mb-[14px] flex items-center gap-3">
|
||||
<span className="text-[12.5px] font-extrabold tracking-[.8px] text-[#3F362E]">SALA SOLES</span>
|
||||
<span className="text-[13px] text-[#A89A8B]">
|
||||
{filtered.length} {filtered.length === 1 ? "niño" : "niños"}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[#E7DAC8]" />
|
||||
</div>
|
||||
|
||||
{/* Grilla filtrada o estado vacío */}
|
||||
{filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-[14px]">
|
||||
{filtered.map((kid) => (
|
||||
<KidCard key={kid.slug} kid={kid} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[18px] border border-dashed border-[#DBCDBA] bg-[#FFFDF9] px-4 py-10 text-center text-[14.5px] text-[#94887B]">
|
||||
No hay niños que coincidan con la búsqueda.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Link from "next/link";
|
||||
import type { Post, PostCategory } from "@/app/data/feed";
|
||||
|
||||
/** Badge por categoría: colores exactos de la plantilla. */
|
||||
const BADGES: Record<PostCategory, { label: string; classes: string }> = {
|
||||
logro: { label: "LOGRO", classes: "bg-[#CFEBD8] text-[#3E9B6C]" },
|
||||
actividad: { label: "ACTIVIDAD", classes: "bg-[#C7E7F1] text-[#2E89A6]" },
|
||||
anuncio: { label: "ANUNCIO", classes: "bg-[#CCD8F4] text-[#4E72C8]" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Colores dinámicos → utilities Tailwind con valores exactos.
|
||||
* El set de pares del mock es cerrado; nunca se usan atributos style.
|
||||
*/
|
||||
const AVATAR_COLORS: Record<string, string> = {
|
||||
"A9D9E8-1F7A93": "bg-[#A9D9E8] text-[#1F7A93]",
|
||||
"CCD8F4-4E72C8": "bg-[#CCD8F4] text-[#4E72C8]",
|
||||
};
|
||||
|
||||
function avatarClasses(post: Post): string {
|
||||
const key = `${post.authorColors.bg.replace("#", "")}-${post.authorColors.text.replace("#", "")}`;
|
||||
return AVATAR_COLORS[key] ?? "";
|
||||
}
|
||||
|
||||
export default function PostCard({ post }: { post: Post }) {
|
||||
const badge = BADGES[post.category];
|
||||
|
||||
return (
|
||||
<article className="rounded-[20px] border border-[#ECE0D0] bg-[#FFFDF9] px-[22px] py-5 shadow-[0_4px_16px_-12px_rgba(120,90,60,.5)]">
|
||||
{/* Encabezado: avatar + autor + badge */}
|
||||
<div className="mb-[14px] flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-11 w-11 flex-none items-center justify-center rounded-full font-display text-[17px] font-semibold ${avatarClasses(post)}`}
|
||||
>
|
||||
{post.authorInitial ? (
|
||||
post.authorInitial
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 11 18-5v12L3 14v-3zM11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-display text-[16.5px] font-semibold text-[#3F362E]">{post.authorName}</div>
|
||||
<div className="text-[12.5px] text-[#A89A8B]">
|
||||
{post.timeLabel} · {post.publishedBy}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-[7px] rounded-full px-3 py-[6px] ${badge.classes}`}>
|
||||
<span className="h-2 w-2 rounded-full bg-current" />
|
||||
<span className="text-xs font-extrabold tracking-[.5px]">{badge.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destinatario */}
|
||||
<div className="mb-2.5 text-[12.5px] text-[#A89A8B]">Para: {post.audience}</div>
|
||||
|
||||
{/* Contenido */}
|
||||
<p className="text-[15.5px] leading-[1.55] text-[#4A4038]">{post.body}</p>
|
||||
|
||||
{/* Placeholder de foto (solo posts de actividad con foto) */}
|
||||
{post.photoLabel && (
|
||||
<Link
|
||||
href="/foto"
|
||||
className="mt-[14px] flex h-[200px] flex-col items-center justify-center gap-2 rounded-2xl border-[1.5px] border-dashed border-[#DBCDBA] bg-[#F4ECE1] text-[#B0A290]"
|
||||
>
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.6-3.6a2 2 0 0 0-2.8 0L6 21" />
|
||||
</svg>
|
||||
<span className="text-[13.5px]">Foto · {post.photoLabel}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Acciones */}
|
||||
<div className="mt-4 flex items-center gap-[18px] border-t border-[#F0E6D8] pt-[14px]">
|
||||
<span className="flex items-center gap-[7px] text-sm font-bold text-[#E0654A]">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="#E0654A" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21.2l7.8-7.8 1-1a5.5 5.5 0 0 0 0-7.8z" />
|
||||
</svg>
|
||||
{post.likes}
|
||||
</span>
|
||||
<Link
|
||||
href="/detalle-publicacion"
|
||||
className="flex items-center gap-[7px] text-sm font-bold text-[#94887B]"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z" />
|
||||
</svg>
|
||||
{post.comments}
|
||||
</Link>
|
||||
<span className="flex-1" />
|
||||
<Link href="/crear-publicacion" className="text-sm font-extrabold text-[#C5503A]">
|
||||
Editar
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ShareBox() {
|
||||
return (
|
||||
<Link
|
||||
href="/crear-publicacion"
|
||||
className="mb-6 flex items-center gap-[14px] rounded-[18px] border border-[#ECE0D0] bg-[#FFFDF9] px-[18px] py-[14px] shadow-[0_4px_14px_-10px_rgba(120,90,60,.4)]"
|
||||
>
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-full bg-[#F2937A] font-display text-base font-semibold text-white">
|
||||
C
|
||||
</div>
|
||||
<span className="flex-1 text-[15px] text-[#A89A8B]">Compartí un momento…</span>
|
||||
<span className="flex h-[38px] w-[38px] items-center justify-center rounded-[12px] bg-[#FBE3D8] text-[#E0654A]">
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</svg>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
href: "/",
|
||||
label: "Feed",
|
||||
icon: (
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 9.5 12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/kids",
|
||||
label: "Niños",
|
||||
icon: (
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="9" cy="7" r="3" />
|
||||
<circle cx="17" cy="9" r="2.4" />
|
||||
<path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 20a5 5 0 0 1 5.5-4.9" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/avisos",
|
||||
label: "Avisos",
|
||||
icon: (
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/mi-cuenta",
|
||||
label: "Mi cuenta",
|
||||
icon: (
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar({ activeHref = "/" }: { activeHref?: string }) {
|
||||
return (
|
||||
<aside className="sticky top-0 flex h-screen w-[248px] flex-none flex-col border-r border-[#ECE0D0] bg-[#FFFDF9] px-4 py-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-[11px] px-2 pb-[22px] pt-1">
|
||||
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-[12px] bg-[linear-gradient(155deg,#F8C3A8,#F2937A)]">
|
||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-display text-[17px] font-semibold leading-none text-[#3F362E]">OpenDayCare</div>
|
||||
<div className="mt-[2px] text-[11.5px] text-[#A89A8B]">Sala Soles</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Nueva publicación */}
|
||||
<Link
|
||||
href="/crear-publicacion"
|
||||
className="mb-[18px] flex w-full items-center justify-center gap-2 rounded-[14px] bg-[linear-gradient(180deg,#F4977E,#EE8164)] py-3 text-[14.5px] font-extrabold text-white shadow-[0_8px_18px_-8px_rgba(238,129,100,.75)]"
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Nueva publicación
|
||||
</Link>
|
||||
|
||||
{/* Navegación */}
|
||||
<nav className="flex flex-1 flex-col gap-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.href === activeHref;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 rounded-xl px-3 py-[11px] text-[14.5px] ${
|
||||
active
|
||||
? "bg-[#FBE3D8] font-extrabold text-[#D9583C]"
|
||||
: "bg-transparent font-semibold text-[#6E6359]"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Usuario */}
|
||||
<div className="mt-[10px] border-t border-[#ECE0D0] pt-[14px]">
|
||||
<div className="flex items-center gap-[11px] px-2 py-[6px]">
|
||||
<div className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[#F2937A] font-display text-base font-semibold text-white">
|
||||
C
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-extrabold text-[#3F362E]">Caro Giménez</div>
|
||||
<div className="text-xs text-[#A89A8B]">Maestra · Soles</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
title="Cerrar sesión"
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-[#F6ECDF] text-[#94887B]"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export type PostCategory = "logro" | "actividad" | "anuncio";
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
authorName: string;
|
||||
/** Inicial para el avatar circular (solo posts de niño; el anuncio usa ícono). */
|
||||
authorInitial: string;
|
||||
/** Colores del avatar, exactos a la plantilla. */
|
||||
authorColors: { bg: string; text: string };
|
||||
timeLabel: string;
|
||||
publishedBy: string;
|
||||
/** Define el badge y sus colores vía mapa en PostCard. */
|
||||
category: PostCategory;
|
||||
/** Texto tras "Para: " (sin el prefijo). */
|
||||
audience: string;
|
||||
body: string;
|
||||
/** Si existe, se muestra el placeholder dashed de foto con este rótulo. */
|
||||
photoLabel?: string;
|
||||
likes: number;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export const posts: Post[] = [
|
||||
{
|
||||
id: "mateo-logro",
|
||||
authorName: "Mateo",
|
||||
authorInitial: "M",
|
||||
authorColors: { bg: "#A9D9E8", text: "#1F7A93" },
|
||||
timeLabel: "14:20",
|
||||
publishedBy: "publicado por vos",
|
||||
category: "logro",
|
||||
audience: "familia de Mateo",
|
||||
body: "¡Usó el orinal solito por primera vez! Estaba feliz de contárselo a todos. Un gran paso.",
|
||||
likes: 3,
|
||||
comments: 1,
|
||||
},
|
||||
{
|
||||
id: "mateo-actividad",
|
||||
authorName: "Mateo",
|
||||
authorInitial: "M",
|
||||
authorColors: { bg: "#A9D9E8", text: "#1F7A93" },
|
||||
timeLabel: "09:40",
|
||||
publishedBy: "publicado por vos",
|
||||
category: "actividad",
|
||||
audience: "familia de Mateo",
|
||||
body: "Pintamos con témperas esta mañana. Mateo eligió el azul para todo y se concentró un montón mezclando colores.",
|
||||
photoLabel: "pintando con témperas",
|
||||
likes: 5,
|
||||
comments: 2,
|
||||
},
|
||||
{
|
||||
id: "anuncio-parque",
|
||||
authorName: "Anuncio general",
|
||||
authorInitial: "",
|
||||
authorColors: { bg: "#CCD8F4", text: "#4E72C8" },
|
||||
timeLabel: "07:50",
|
||||
publishedBy: "publicado por vos",
|
||||
category: "anuncio",
|
||||
audience: "toda la sala",
|
||||
body: "El viernes salimos al parque por la mañana. Recuerden mandar gorra y una botellita de agua.",
|
||||
likes: 8,
|
||||
comments: 0,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,151 @@
|
||||
export type ParentStatus = "activa" | "pendiente";
|
||||
|
||||
export interface Parent {
|
||||
name: string;
|
||||
/** "Mamá" | "Papá" */
|
||||
role: string;
|
||||
/** Inicial para el avatar circular. */
|
||||
initial: string;
|
||||
/** Color de fondo del avatar, exacto a la plantilla. */
|
||||
bg: string;
|
||||
/** Texto tras "Rol · " en el perfil: "activa" | "invitación enviada". */
|
||||
note: string;
|
||||
/** Define el badge ACTIVA (verde) / PENDIENTE (amarillo). */
|
||||
status: ParentStatus;
|
||||
}
|
||||
|
||||
export interface Kid {
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Inicial para el avatar circular. */
|
||||
initial: string;
|
||||
/** Colores del avatar, exactos a la plantilla. */
|
||||
avatar: { bg: string; text: string };
|
||||
/** Se renderiza como "X años". */
|
||||
age: number;
|
||||
/** Badges de la lista; vacío = chevron. La primera se muestra en la tarjeta. */
|
||||
allergies: string[];
|
||||
/** Texto del recuadro naranja del perfil (si no hay, no se muestra). */
|
||||
allergyNote?: string;
|
||||
birthDate: string;
|
||||
room: string;
|
||||
admission: string;
|
||||
parents: Parent[];
|
||||
}
|
||||
|
||||
export const kids: Kid[] = [
|
||||
{
|
||||
slug: "mateo-fernandez",
|
||||
name: "Mateo Fernández",
|
||||
initial: "M",
|
||||
avatar: { bg: "#A9D9E8", text: "#1F7A93" },
|
||||
age: 3,
|
||||
allergies: ["MANÍ"],
|
||||
allergyNote: "Alergia al maní. Evitar frutos secos. Lleva inhalador en la mochila.",
|
||||
birthDate: "12 mar 2022",
|
||||
room: "Soles",
|
||||
admission: "feb 2025",
|
||||
parents: [
|
||||
{ name: "Lucía Fernández", role: "Mamá", initial: "L", bg: "#C9B6E8", note: "activa", status: "activa" },
|
||||
{ name: "Diego Fernández", role: "Papá", initial: "D", bg: "#A9C7E8", note: "invitación enviada", status: "pendiente" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "sofia-mendez",
|
||||
name: "Sofía Méndez",
|
||||
initial: "S",
|
||||
avatar: { bg: "#F4B8CC", text: "#C44A7A" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "3 jul 2023",
|
||||
room: "Soles",
|
||||
admission: "mar 2025",
|
||||
parents: [
|
||||
{ name: "Valeria Méndez", role: "Mamá", initial: "V", bg: "#F4B8CC", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "benjamin-ruiz",
|
||||
name: "Benjamín Ruiz",
|
||||
initial: "B",
|
||||
avatar: { bg: "#B9DEC4", text: "#3E8B62" },
|
||||
age: 3,
|
||||
allergies: [],
|
||||
birthDate: "28 nov 2022",
|
||||
room: "Soles",
|
||||
admission: "ago 2024",
|
||||
parents: [
|
||||
{ name: "Marcela Ruiz", role: "Mamá", initial: "M", bg: "#B9DEC4", note: "activa", status: "activa" },
|
||||
{ name: "Javier Ruiz", role: "Papá", initial: "J", bg: "#A9C7E8", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "valentina-soto",
|
||||
name: "Valentina Soto",
|
||||
initial: "V",
|
||||
avatar: { bg: "#F4DC8E", text: "#9A7B1E" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "9 may 2023",
|
||||
room: "Soles",
|
||||
admission: "feb 2025",
|
||||
parents: [],
|
||||
},
|
||||
{
|
||||
slug: "tomas-diaz",
|
||||
name: "Tomás Díaz",
|
||||
initial: "T",
|
||||
avatar: { bg: "#C9B6E8", text: "#7B5FC0" },
|
||||
age: 3,
|
||||
allergies: ["LACTOSA"],
|
||||
allergyNote: "Intolerancia a la lactosa. Toma leche deslactosada y evita los lácteos en la merienda.",
|
||||
birthDate: "17 sep 2022",
|
||||
room: "Soles",
|
||||
admission: "sep 2023",
|
||||
parents: [
|
||||
{ name: "Andrés Díaz", role: "Papá", initial: "A", bg: "#C9B6E8", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "emma-castro",
|
||||
name: "Emma Castro",
|
||||
initial: "E",
|
||||
avatar: { bg: "#F4B8CC", text: "#C44A7A" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "2 ene 2023",
|
||||
room: "Soles",
|
||||
admission: "mar 2025",
|
||||
parents: [
|
||||
{ name: "Camila Castro", role: "Mamá", initial: "C", bg: "#F2937A", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "lucas-romero",
|
||||
name: "Lucas Romero",
|
||||
initial: "L",
|
||||
avatar: { bg: "#A9D9E8", text: "#1F7A93" },
|
||||
age: 3,
|
||||
allergies: [],
|
||||
birthDate: "30 abr 2022",
|
||||
room: "Soles",
|
||||
admission: "feb 2024",
|
||||
parents: [
|
||||
{ name: "Martín Romero", role: "Papá", initial: "M", bg: "#A9C7E8", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "olivia-vega",
|
||||
name: "Olivia Vega",
|
||||
initial: "O",
|
||||
avatar: { bg: "#B9DEC4", text: "#3E8B62" },
|
||||
age: 2,
|
||||
allergies: [],
|
||||
birthDate: "21 oct 2023",
|
||||
room: "Soles",
|
||||
admission: "ago 2025",
|
||||
parents: [
|
||||
{ name: "Julieta Vega", role: "Mamá", initial: "J", bg: "#F4DC8E", note: "activa", status: "activa" },
|
||||
],
|
||||
},
|
||||
];
|
||||
+19
-19
@@ -1,26 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--font-sans: var(--font-nunito), system-ui, sans-serif;
|
||||
--font-display: var(--font-fredoka), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Tema claro fijo: la plantilla no define modo oscuro */
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: #F6ECDF;
|
||||
color: #3F362E;
|
||||
font-family: var(--font-nunito), system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar estilo plantilla */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #E4D6C4;
|
||||
border-radius: 8px;
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import Sidebar from "@/app/components/Sidebar";
|
||||
import { avatarClasses } from "@/app/components/KidCard";
|
||||
import type { Kid, Parent, ParentStatus } from "@/app/data/kids";
|
||||
import { kids } from "@/app/data/kids";
|
||||
|
||||
/** Página 100% estática que deriva de params: permitido bloquear, sin Suspense. */
|
||||
export const instant = false;
|
||||
|
||||
/** Los 8 slugs del mock se prerenderizan; cualquier otro cae en notFound(). */
|
||||
export function generateStaticParams() {
|
||||
return kids.map((kid) => ({ slug: kid.slug }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Colores dinámicos → utilities Tailwind con valores exactos.
|
||||
* El set de fondos del mock es cerrado; nunca se usan atributos style.
|
||||
*/
|
||||
const PARENT_BG: Record<string, string> = {
|
||||
C9B6E8: "bg-[#C9B6E8]",
|
||||
A9C7E8: "bg-[#A9C7E8]",
|
||||
F4B8CC: "bg-[#F4B8CC]",
|
||||
B9DEC4: "bg-[#B9DEC4]",
|
||||
F2937A: "bg-[#F2937A]",
|
||||
F4DC8E: "bg-[#F4DC8E]",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<ParentStatus, { label: string; classes: string }> = {
|
||||
activa: { label: "ACTIVA", classes: "bg-[#CFEBD8] text-[#3E9B6C]" },
|
||||
pendiente: { label: "PENDIENTE", classes: "bg-[#F7E7A6] text-[#9A7B1E]" },
|
||||
};
|
||||
|
||||
function parentClasses(parent: Parent): string {
|
||||
return PARENT_BG[parent.bg.replace("#", "")] ?? "";
|
||||
}
|
||||
|
||||
function ParentRow({ parent }: { parent: Parent }) {
|
||||
const badge = STATUS_BADGE[parent.status];
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 flex-none items-center justify-center rounded-full font-display text-base font-semibold text-white ${parentClasses(parent)}`}
|
||||
>
|
||||
{parent.initial}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14.5px] font-extrabold text-[#3F362E]">{parent.name}</div>
|
||||
<div className="text-[12.5px] text-[#A89A8B]">
|
||||
{parent.role} · {parent.note}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex-none rounded-full px-[9px] py-1 text-[10.5px] font-extrabold ${badge.classes}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function KidProfilePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const kid: Kid | undefined = kids.find((candidate) => candidate.slug === slug);
|
||||
if (!kid) notFound();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#F6ECDF]">
|
||||
<Sidebar activeHref="/kids" />
|
||||
<main className="h-screen min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[820px] px-10 pb-20 pt-[34px]">
|
||||
{/* Breadcrumb */}
|
||||
<Link
|
||||
href="/kids"
|
||||
className="mb-5 flex items-center gap-[7px] text-[14px] font-bold text-[#94887B]"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Volver a Niños
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-[26px]">
|
||||
{/* Columna izquierda */}
|
||||
<div className="flex min-w-[300px] flex-1 flex-col gap-[18px]">
|
||||
{/* Identidad */}
|
||||
<div className="flex items-center gap-[18px]">
|
||||
<div
|
||||
className={`flex h-[84px] w-[84px] flex-none items-center justify-center rounded-full font-display text-[34px] font-semibold ${avatarClasses(kid)}`}
|
||||
>
|
||||
{kid.initial}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="font-display text-[28px] font-semibold leading-[1.2] text-[#3F362E]">{kid.name}</h1>
|
||||
<p className="mt-[3px] text-[15px] text-[#94887B]">
|
||||
{kid.age} años · Sala {kid.room}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/agregar-nino"
|
||||
className="rounded-[12px] border-[1.5px] border-[#ECE0D0] bg-[#FFFDF9] px-4 py-[9px] text-sm font-bold text-[#6E6359]"
|
||||
>
|
||||
Editar
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Alergias y notas (solo si tiene) */}
|
||||
{kid.allergyNote && (
|
||||
<div className="flex gap-[14px] rounded-[16px] bg-[#FBDAD6] px-[18px] py-4">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[11px] bg-[#F4A8A0]">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
|
||||
<path d="M12 9v4M12 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-[2px] text-[15px] font-extrabold text-[#C5413A]">Alergias y notas</div>
|
||||
<div className="text-[14.5px] leading-[1.5] text-[#B25249]">{kid.allergyNote}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Datos */}
|
||||
<div className="overflow-hidden rounded-[16px] border border-[#ECE0D0] bg-[#FFFDF9]">
|
||||
<div className="flex justify-between border-b border-[#F0E6D8] px-[18px] py-[15px]">
|
||||
<span className="text-[14.5px] text-[#94887B]">Fecha de nacimiento</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.birthDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-[#F0E6D8] px-[18px] py-[15px]">
|
||||
<span className="text-[14.5px] text-[#94887B]">Sala</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.room}</span>
|
||||
</div>
|
||||
<div className="flex justify-between px-[18px] py-[15px]">
|
||||
<span className="text-[14.5px] text-[#94887B]">Ingreso</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#3F362E]">{kid.admission}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna derecha */}
|
||||
<div className="flex w-[300px] flex-none flex-col gap-[14px]">
|
||||
<Link
|
||||
href="/resumen-dia"
|
||||
className="flex w-full items-center justify-center gap-[9px] rounded-[14px] bg-[#3F362E] py-[13px] text-[15px] font-extrabold text-white"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
Resumen del día
|
||||
</Link>
|
||||
|
||||
{/* Padres vinculados */}
|
||||
<div className="rounded-[16px] border border-[#ECE0D0] bg-[#FFFDF9] px-[18px] py-4">
|
||||
<div className="mb-[14px] text-[12.5px] font-extrabold tracking-[.8px] text-[#8A7C6D]">
|
||||
PADRES VINCULADOS
|
||||
</div>
|
||||
<div className="flex flex-col gap-[14px]">
|
||||
{kid.parents.map((parent) => (
|
||||
<ParentRow key={parent.name} parent={parent} />
|
||||
))}
|
||||
<Link href="/vincular-padre" className="flex items-center gap-3 pt-2">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-full border-[1.5px] border-dashed border-[#D8CBBA] text-[#B0A290]">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-[14.5px] font-extrabold text-[#C5503A]">Vincular otro padre</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
import KidsBrowser from "@/app/components/KidsBrowser";
|
||||
import Sidebar from "@/app/components/Sidebar";
|
||||
import { kids } from "@/app/data/kids";
|
||||
|
||||
export default function KidsPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#F6ECDF]">
|
||||
<Sidebar activeHref="/kids" />
|
||||
<main className="h-screen min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[880px] px-10 pb-20 pt-[34px]">
|
||||
{/* Encabezado */}
|
||||
<div className="mb-[22px] flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="mb-1 text-[12.5px] font-extrabold tracking-[.8px] text-[#D9583C]">GESTIÓN</div>
|
||||
<h1 className="font-display text-[30px] font-semibold leading-[1.2] text-[#3F362E]">Niños</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/agregar-nino"
|
||||
className="flex items-center gap-2 rounded-[14px] bg-[linear-gradient(180deg,#F4977E,#EE8164)] px-[18px] py-[11px] text-[14.5px] font-extrabold text-white shadow-[0_8px_18px_-8px_rgba(238,129,100,.7)]"
|
||||
>
|
||||
<svg
|
||||
width="17"
|
||||
height="17"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#fff"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Agregar niño
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<KidsBrowser kids={kids} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-9
@@ -1,27 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Fredoka, Nunito } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const fredoka = Fredoka({
|
||||
variable: "--font-fredoka",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const nunito = Nunito({
|
||||
variable: "--font-nunito",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "OpenDayCare",
|
||||
description: "Feed de la guardería OpenDayCare · Sala Soles",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
lang="es"
|
||||
className={`${fredoka.variable} ${nunito.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
|
||||
+23
-60
@@ -1,67 +1,30 @@
|
||||
import Image from "next/image";
|
||||
import FeedHeader from "@/app/components/FeedHeader";
|
||||
import PostCard from "@/app/components/PostCard";
|
||||
import ShareBox from "@/app/components/ShareBox";
|
||||
import Sidebar from "@/app/components/Sidebar";
|
||||
import { posts } from "@/app/data/feed";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
<div className="flex min-h-screen bg-[#F6ECDF]">
|
||||
<Sidebar activeHref="/" />
|
||||
<main className="h-screen min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[760px] px-10 pb-20 pt-[34px]">
|
||||
<FeedHeader />
|
||||
<ShareBox />
|
||||
|
||||
<div className="mb-[14px] flex items-center gap-[14px]">
|
||||
<span className="text-[12.5px] font-extrabold tracking-[.8px] text-[#8A7C6D]">
|
||||
PUBLICADO HOY
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[#E7DAC8]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# spec workflow configuration
|
||||
#
|
||||
# AutoCreateBranch — controls whether /spec-impl creates the git branch automatically.
|
||||
# true (default) → /spec-impl creates and switches to spec-NN-slug without asking
|
||||
# false → /spec-impl asks for [y/N] confirmation before creating the branch
|
||||
AutoCreateBranch: true
|
||||
@@ -0,0 +1,98 @@
|
||||
# SPEC 01 — Home: feed de la guardería (plantilla feed.dc.html)
|
||||
|
||||
> **Estado:** Verificada (2026-10-20)
|
||||
> **Depende de:** —
|
||||
> **Fecha:** 2026-10-19
|
||||
> **Objetivo:** Implementar la plantilla `references/pantallas/feed.dc.html` como página home (`/`) en Next.js, visualmente idéntica al original, sin autenticación ni base de datos.
|
||||
|
||||
## Alcance
|
||||
|
||||
**In:**
|
||||
|
||||
- Página `/` (`app/page.tsx`) con: sidebar sticky de 248px (logo OpenDayCare · Sala Soles, botón "Nueva publicación", nav con Feed activo + Niños/Avisos/Mi cuenta, tarjeta de usuario "Caro Giménez" con logout), saludo ("GUARDERÍA · SALA SOLES" / "Buenas, Caro" / "12 niños · martes 17 jun"), caja "Compartí un momento…", separador "PUBLICADO HOY" y los 3 posts de la plantilla (logro, actividad con placeholder de foto, anuncio).
|
||||
- Datos mock tipados en `app/data/feed.ts` con los 3 posts exactos de la plantilla.
|
||||
- Componentes en `app/components/`: `Sidebar.tsx`, `FeedHeader.tsx`, `ShareBox.tsx`, `PostCard.tsx`.
|
||||
- Cambio de fuentes en `app/layout.tsx` (Fredoka + Nunito vía `next/font/google`), `lang="es"` y metadata "OpenDayCare".
|
||||
- Estilos base en `app/globals.css` (fondo #F6ECDF, texto #3F362E, scrollbar custom, tema claro fijo).
|
||||
- Todos los links como `<Link>` a rutas futuras (`/crear-publicacion`, `/ninos`, `/avisos`, `/mi-cuenta`, `/detalle-publicacion`, `/login`, `/foto` — este último en el placeholder de foto, como `foto.dc.html` en la plantilla).
|
||||
|
||||
**Out of scope (para specs futuras):**
|
||||
|
||||
- Las páginas destino del nav y sus funcionalidades (hoy darán 404 y está bien).
|
||||
- Autenticación y logout funcional.
|
||||
- Base de datos y persistencia.
|
||||
- Interactividad real de likes, comentarios y editar (solo visual, contadores fijos).
|
||||
- Responsive móvil.
|
||||
- Dark mode.
|
||||
- Foto real en el post de actividad (se mantiene el placeholder dashed de la plantilla).
|
||||
|
||||
## Modelo de datos
|
||||
|
||||
```ts
|
||||
// app/data/feed.ts
|
||||
export type PostCategory = "logro" | "actividad" | "anuncio";
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
authorName: string; // "Mateo" | "Anuncio general"
|
||||
authorInitial: string; // "M" (solo posts de niño)
|
||||
authorColors: { bg: string; text: string }; // p.ej. { bg: "#A9D9E8", text: "#1F7A93" }
|
||||
timeLabel: string; // "14:20"
|
||||
publishedBy: string; // "publicado por vos"
|
||||
category: PostCategory; // define badge y colores vía mapa en PostCard
|
||||
audience: string; // "familia de Mateo" | "toda la sala"
|
||||
body: string;
|
||||
photoLabel?: string; // "pintando con témperas" → placeholder dashed
|
||||
likes: number;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export const posts: Post[] = [
|
||||
// Los 3 posts de la plantilla: logro (3/1), actividad con foto (5/2), anuncio (8/0)
|
||||
];
|
||||
```
|
||||
|
||||
Convenciones: los colores van como hex exactos de la plantilla; el mapa `category → colores del badge` vive en `PostCard.tsx`.
|
||||
|
||||
## Plan de implementación
|
||||
|
||||
1. **Fuentes y layout** — `app/layout.tsx`: reemplazar Geist por Fredoka y Nunito como fuentes variables con `next/font/google` (variables `--font-fredoka` / `--font-nunito`), `lang="es"`, title "OpenDayCare". Test manual: `npm run dev` sigue compilando.
|
||||
2. **Estilos globales** — `app/globals.css`: body con `background:#F6ECDF`, `color:#3F362E`, `font-family: var(--font-nunito), system-ui, sans-serif`; scrollbar custom (thumb `#E4D6C4`); eliminar el bloque `prefers-color-scheme` y las variables Geist. Test manual: la app ya se ve crema.
|
||||
3. **Mock del feed** — crear `app/data/feed.ts` con el tipo `Post` y los 3 posts de la plantilla.
|
||||
4. **Sidebar** — crear `app/components/Sidebar.tsx` con logo, botón "Nueva publicación" (→ `/crear-publicacion`), nav (Feed activo, Niños, Avisos, Mi cuenta) y usuario + logout (→ `/login`).
|
||||
5. **Componentes del feed** — crear `FeedHeader.tsx`, `ShareBox.tsx` y `PostCard.tsx` con utilities Tailwind de valores exactos.
|
||||
6. **Página home** — reemplazar `app/page.tsx`: flex con Sidebar + `<main>` de scroll propio, contenido centrado `max-width:760px`, mapeando `posts`. Test manual: `http://localhost:3000` muestra el feed completo.
|
||||
7. **Fidelidad visual** — comparación lado a lado con `references/pantallas/feed.dc.html` a ancho ≥1280px; ajustar diferencias. Captura de verificación en `.playwright-mcp/`.
|
||||
|
||||
## Criterios de aceptación
|
||||
|
||||
- [x] `npm run dev` levanta sin errores y `/` muestra el feed completo: sidebar + saludo + caja + separador + 3 posts. *(Verificado 2026-10-20 en `localhost:3001` a 1440×900; consola sin errores de compilación ni hidratación. Capturas: `.playwright-mcp/verify-feed-app-1440.png`, `.playwright-mcp/verify-feed-app-bottom.png`.)*
|
||||
- [x] `npm run build` completa sin errores. *(Next.js 16.4.2 + Turbopack: compilación, TypeScript y prerender de `/` OK.)*
|
||||
- [x] A ancho ≥1280px el layout coincide con la plantilla: sidebar 248px sticky, main con scroll propio y contenido centrado de 760px. *(Computed styles: `aside` 248px sticky top-0 h-screen; `main` h-screen overflow-y auto; contenedor max-width 760px centrado — márgenes 211px/211px a 1440px.)*
|
||||
- [x] Fredoka en logo, saludo y nombres de autor; Nunito (pesos 400–800) en el resto. *(Computed: Fredoka 17px/600 logo, 30px/600 h1, 16.5px/600 autores; Nunito 400 texto, 600 nav inactivo, 700 likes, 800 eyebrow/nav activo. Fuentes variables self-hosted vía `next/font/google`.)*
|
||||
- [x] Post 1: badge LOGRO (fondo `#CFEBD8`, texto `#3E9B6C`), ♥ 3, 💬 1, link "Editar". *(Computed: `rgb(207,235,216)` / `rgb(62,155,108)`; acciones 3 / 1 / Editar → `/crear-publicacion`.)*
|
||||
- [x] Post 2: badge ACTIVIDAD (fondo `#C7E7F1`, texto `#2E89A6`), placeholder dashed "Foto · pintando con témperas", ♥ 5, 💬 2. *(Computed: `rgb(199,231,241)` / `rgb(46,137,166)`; placeholder dashed 1.5px `#DBCDBA`, 200px alto, link a `/foto`; acciones 5 / 2.)*
|
||||
- [x] Post 3: avatar megáfono (fondo `#CCD8F4`), badge ANUNCIO, "Para: toda la sala", ♥ 8, 💬 0. *(Computed: avatar 44px `rgb(204,216,244)` con SVG megáfono idéntico a la plantilla; badge `rgb(204,216,244)` / `rgb(78,114,200)`; acciones 8 / 0.)*
|
||||
- [x] Todos los links navegan con `<Link>`: `/crear-publicacion` (botón, caja y Editar), `/ninos`, `/avisos`, `/mi-cuenta`, `/detalle-publicacion` (íconos de comentarios), `/login` (logout), `/foto` (placeholder de foto). *(Hrefs del DOM verificados; `next/link` importado en Sidebar, ShareBox y PostCard; sin `<a>` planos.)*
|
||||
- [x] Colores base exactos: fondo `#F6ECDF`, tarjetas `#FFFDF9` con borde `#ECE0D0`, sombras y radios según plantilla. *(Computed: body `rgb(246,236,223)`; tarjetas `rgb(255,253,249)`, borde 1px `rgb(236,224,208)`, radio 20px, sombra `rgba(120,90,60,.5) 0 4px 16px -12px`; caja 18px.)*
|
||||
- [x] Con el sistema en modo oscuro la página se mantiene en tema claro (sin dark mode). *(Emulación `prefers-color-scheme: dark` en Playwright: body, aside y tarjetas mantienen sus colores claros. Captura: `.playwright-mcp/verify-feed-darkmode.png`.)*
|
||||
- [x] Los componentes nuevos no usan atributos `style`; todo está en utilities Tailwind con valores arbitrarios exactos. *(Grep `style=` en `app/**/*.{tsx,css}`: 0 resultados; los únicos `[style]` del DOM son internos de Next.js — `nextjs-portal`, `next-route-announcer` — fuera de los componentes.)*
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Sí:** Tailwind 4 con valores arbitrarios exactos (`bg-[#F6ECDF]`, `shadow-[0_4px_16px_-12px_rgba(120,90,60,.5)]`…) — consistente con el proyecto y pixel-perfect.
|
||||
- **No:** estilos inline literales ni CSS Modules — divergen de la convención del repo.
|
||||
- **Sí:** componentes separados en `app/components/` — el sidebar se reutiliza en Niños, Avisos y Mi cuenta.
|
||||
- **Sí:** mock tipado en `app/data/feed.ts` — deja el terreno listo para la BD futura.
|
||||
- **Sí:** `<Link>` a rutas futuras aunque hoy den 404 — la navegación queda cableada desde el día uno.
|
||||
- **Sí:** solo desktop, réplica exacta — el móvil merece decisiones de diseño propias (otra spec).
|
||||
- **Sí:** `next/font/google` para Fredoka y Nunito como fuentes variables — misma familia y pesos que el `<link>` de la plantilla, self-hosted y sin FOUT.
|
||||
- **Sí:** tema claro fijo, eliminando el dark mode automático de `globals.css` — la plantilla no define modo oscuro.
|
||||
- **No:** autenticación, BD, likes/comentarios/editar funcionales ni foto real — excluidos explícitamente por el usuario.
|
||||
|
||||
## Qué **no** está en esta spec
|
||||
|
||||
- Otras páginas (Niños, Avisos, Mi cuenta, crear/editar publicación, detalle, login) — cada una irá en su propia spec.
|
||||
- Autenticación y base de datos.
|
||||
- Likes, comentarios y edición funcionales.
|
||||
- Responsive móvil y dark mode.
|
||||
@@ -0,0 +1,114 @@
|
||||
# SPEC 02 — Niños: listado y perfil (`/kids` y `/kids/[slug]`)
|
||||
|
||||
> **Estado:** Verificada (2026-10-20)
|
||||
> **Depende de:** SPEC 01
|
||||
> **Fecha:** 2026-10-20
|
||||
> **Objetivo:** Implementar las plantillas `references/pantallas/ninos.dc.html` y `references/pantallas/perfil-nino.dc.html` como `/kids` y `/kids/[slug]`, visualmente idénticas, con mock de 8 niños con ficha completa y buscador funcional.
|
||||
|
||||
## Alcance
|
||||
|
||||
**In:**
|
||||
|
||||
- Página `/kids` (`app/kids/page.tsx`): sidebar con "Niños" activo, encabezado (eyebrow "GESTIÓN" + título "Niños"), botón "Agregar niño" (→ `/agregar-nino`), buscador y grilla de 2 columnas con las 8 tarjetas de la plantilla.
|
||||
- Buscador **funcional** (filtro client-side): filtra por nombre mientras se escribe, el contador "8 niños" se actualiza y muestra estado vacío si no hay coincidencias.
|
||||
- Página `/kids/[slug]` (`app/kids/[slug]/page.tsx`): breadcrumb "Volver a Niños" (→ `/kids`), avatar 84px + nombre + "3 años · Sala Soles", botón "Editar" (→ `/agregar-nino`), recuadro naranja "Alergias y notas" (solo si el niño tiene notas), tarjeta de datos (nacimiento / sala / ingreso), columna derecha con botón "Resumen del día" (→ `/resumen-dia`), tarjeta "PADRES VINCULADOS" (badges ACTIVA/PENDIENTE) y link "Vincular otro padre" (→ `/vincular-padre`).
|
||||
- Las 8 fichas de perfil existen: cada slug renderiza los datos propios del niño (nombre, avatar, alergias, fechas y padres), coherentes con la lista.
|
||||
- Mock tipado en `app/data/kids.ts` con los 8 niños de la plantilla (Mateo con los datos exactos del perfil; los otros 7 con datos inventados coherentes con su fila de la lista).
|
||||
- Componentes nuevos en `app/components/`: `KidCard.tsx` (tarjeta de la lista) y `KidsBrowser.tsx` (cliente: buscador + contador + grilla + estado vacío).
|
||||
- Update de `app/components/Sidebar.tsx`: el link "Niños" pasa de `/ninos` a `/kids` (única referencia existente; SPEC 01 la cableó así).
|
||||
- Hover de tarjeta según plantilla: borde `#F2A78E` + `translateY(-2px)` con transición de 150ms.
|
||||
|
||||
**Out of scope (para specs futuras):**
|
||||
|
||||
- Páginas `/agregar-nino`, `/vincular-padre` y `/resumen-dia` (hoy dan 404 y está bien).
|
||||
- Alta/edición real de niños y vinculación real de padres.
|
||||
- Autenticación, base de datos y persistencia.
|
||||
- Responsive móvil y dark mode.
|
||||
|
||||
## Modelo de datos
|
||||
|
||||
```ts
|
||||
// app/data/kids.ts
|
||||
export type ParentStatus = "activa" | "pendiente";
|
||||
|
||||
export interface Parent {
|
||||
name: string; // "Lucía Fernández"
|
||||
role: string; // "Mamá" | "Papá"
|
||||
initial: string; // "L"
|
||||
bg: string; // "#C9B6E8"
|
||||
status: ParentStatus; // badge ACTIVA (verde) | PENDIENTE (amarillo)
|
||||
}
|
||||
|
||||
export interface Kid {
|
||||
slug: string; // "mateo-fernandez"
|
||||
name: string; // "Mateo Fernández"
|
||||
initial: string; // "M"
|
||||
avatar: { bg: string; text: string }; // { bg: "#A9D9E8", text: "#1F7A93" }
|
||||
age: number; // 3 → se renderiza "3 años"
|
||||
allergies: string[]; // ["MANÍ"] — badges de la lista (vacos = sin badge)
|
||||
allergyNote?: string; // texto del recuadro del perfil (si existe → se muestra)
|
||||
birthDate: string; // "12 mar 2022"
|
||||
room: string; // "Soles"
|
||||
admission: string; // "feb 2025"
|
||||
parents: Parent[];
|
||||
}
|
||||
```
|
||||
|
||||
- Los 8 niños con sus colores exactos de la plantilla: Mateo `#A9D9E8/#1F7A93`, Sofía `#F4B8CC/#C44A7A`, Benjamín `#B9DEC4/#3E8B62`, Valentina `#F4DC8E/#9A7B1E`, Tomás `#C9B6E8/#7B5FC0`, Emma `#F4B8CC/#C44A7A`, Lucas `#A9D9E8/#1F7A93`, Olivia `#B9DEC4/#3E8B62`.
|
||||
- Regla del badge lateral en la lista (deducida de la plantilla): sin padres → badge "VINCULAR" (`#F9D2DE`/`#C56486`); con alergia → primera alergia (`#FBD8CC`/`#D9684A`); resto → chevron `#CBB89F`.
|
||||
- Padres de Mateo (exactos de la plantilla): Lucía Fernández (Mamá, `#C9B6E8`, activa) y Diego Fernández (Papá, `#A9C7E8`, pendiente "invitación enviada").
|
||||
|
||||
Convenciones heredadas de SPEC 01: colores como hex exactos en utilities Tailwind arbitrarias; cero atributos `style`; todo server components salvo `KidsBrowser`.
|
||||
|
||||
## Plan de implementación
|
||||
|
||||
1. **Mock** — crear `app/data/kids.ts` con los tipos y los 8 niños. Test manual: `npm run dev` compila.
|
||||
2. **KidCard** — crear `app/components/KidCard.tsx`: avatar circular, nombre, "X años · N padre(s) vinculado(s)", badge según la regla y link a `/kids/[slug]` con hover de plantilla. Test manual: se ve correcta suelta en cualquier página temporal.
|
||||
3. **KidsBrowser (cliente)** — crear `app/components/KidsBrowser.tsx`: input "Buscar niño…", fila "SALA SOLES + contador + línea divisoria", grilla `grid-cols-2` filtrada por nombre (comparación sin mayúsculas ni acentos vía `normalize("NFD")`) y mensaje de estado vacío.
|
||||
4. **Página /kids** — crear `app/kids/page.tsx`: Sidebar `activeHref="/kids"`, encabezado, botón "Agregar niño" y `<KidsBrowser kids={kids}>`. Test manual: `http://localhost:3000/kids` muestra las 8 tarjetas y el filtro funciona.
|
||||
5. **Sidebar** — en `app/components/Sidebar.tsx` cambiar el href de "Niños" de `/ninos` a `/kids`. Test manual: el link navega y ninguna página queda rota.
|
||||
6. **Página /kids/[slug]** — crear `app/kids/[slug]/page.tsx` con `generateStaticParams` y `notFound()`: en Next.js 16 `params` es una Promise (`const { slug } = await params`). Layout de dos columnas (columna izquierda flexible + derecha fija de 300px), ficha completa, recuadro de alergias condicionado y tarjeta de padres con badges ACTIVA/PENDIENTE. Test manual: `/kids/mateo-fernandez` replica la plantilla y `/kids/valentina-soto` muestra su ficha sin recuadro de alergias ni padres.
|
||||
7. **Fidelidad visual** — comparación lado a lado de ambas pantallas contra las plantillas a ancho ≥1280px; ajustar diferencias. Capturas de verificación en `.playwright-mcp/`.
|
||||
|
||||
## Criterios de aceptación
|
||||
|
||||
- [x] `npm run dev` levanta sin errores y `/kids` muestra eyebrow "GESTIÓN", título "Niños", botón "Agregar niño", buscador y las 8 tarjetas en grilla de 2 columnas. *(Verificado 2026-10-20 en `localhost:3001` a 1440×900; consola limpia tras el opt-out `instant = false`. Computed: h1 "Niños" Fredoka 30px, `grid-template-columns: 393px 393px`, 8 tarjetas. Captura: `.playwright-mcp/verify-kids-final.png`.)*
|
||||
- [x] `npm run build` completa sin errores, incluyendo el prerender de `/kids` y de los 8 slugs. *(Next.js 16.4.2 + Turbopack: compilación y TypeScript OK, 14 páginas estáticas — `/kids` ○ y los 8 slugs listados como estáticos.)*
|
||||
- [x] Escribir "sof" en el buscador deja solo la tarjeta de Sofía Méndez, el contador se actualiza y escribir algo sin coincidencias muestra el estado vacío. *(Computed: "sof" → 1 tarjeta "Sofía Méndez" y contador "1 niño"; "zzzqx" → 0 tarjetas, mensaje "No hay niños que coincidan con la búsqueda." y "0 niños".)*
|
||||
- [x] Escribir "sofia" (sin acento) también encuentra a Sofía Méndez. *(Computed: "sofia" sin acento devuelve exactamente esa tarjeta.)*
|
||||
- [x] Cada tarjeta enlaza con `<Link>` al slug de su niño (8 hrefs distintos) y el badge es correcto: Mateo "MANÍ", Tomás "LACTOSA", Valentina "VINCULAR", resto chevron. *(Computed: 8 hrefs `/kids/<slug>` únicos; badges correctos; `next/link` importado en `KidCard` y 0 `<a>` planos en los archivos nuevos.)*
|
||||
- [x] `/kids/mateo-fernandez` replica la plantilla: breadcrumb, avatar 84px "M", nombre, "3 años · Sala Soles", botón "Editar", recuadro "Alergias y notas" con el texto exacto ("Alergia al maní. Evitar frutos secos. Lleva inhalador en la mochila."), filas nacimiento/sala/ingreso, botón "Resumen del día" y tarjeta "PADRES VINCULADOS" con Lucía (ACTIVA, verde) y Diego (PENDIENTE, amarillo) + "Vincular otro padre". *(Computed: breadcrumb → `/kids`, avatar 84×84 Fredoka "M", texto de alergias literal, filas "12 mar 2022 / Soles / feb 2025", badges `rgb(207,235,216)`/`rgb(62,155,108)` y `rgb(247,231,166)`/`rgb(154,123,30)`. Captura: `.playwright-mcp/verify-perfil-final.png`; comparación visual contra `perfil-nino.dc.html` sin diferencias.)*
|
||||
- [x] Los otros 7 slugs muestran la ficha propia de cada niño (nombre, avatar, edad, padres y alergias coherentes con la lista); Valentina no tiene recuadro de alergias ni padres listados. *(Fetch del HTML SSR de cada slug: nombre, inicial de avatar y edad·sala correctos; recuadro de alergias solo en Tomás; filas de padres 1/2/0/1/1/1/1 según la lista; Valentina sin recuadro y sin badges.)*
|
||||
- [x] `/kids/no-existe` responde 404. *(HTTP 404 real con página de not-found; patrón `generateStaticParams` + `notFound()` tras `await params` validado contra la doc de Next.js (Context7) y `node_modules/next/dist/docs/`.)*
|
||||
- [x] En el sidebar, "Niños" apunta a `/kids` y aparece activo (fondo `#FBE3D8`, texto `#D9583C`) tanto en `/kids` como en `/kids/[slug]`; en `/` sigue activo "Feed". *(Computed en `/kids`, `/kids/tomas-diaz` y `/`: activo "Niños" con `rgb(251,227,216)`/`rgb(217,88,60)` en lista y ficha; en `/` el activo es "Feed" y "Niños" queda inactivo `rgb(110,99,89)`.)*
|
||||
- [x] Los links futuros del perfil navegan con `<Link>`: Editar → `/agregar-nino`, Resumen del día → `/resumen-dia`, Vincular otro padre → `/vincular-padre` (hoy 404, aceptado). *(Computed: los 3 hrefs presentes en el DOM vía `next/link`; destinos 404 según lo acordado.)*
|
||||
- [x] A ancho ≥1280px ambas pantallas coinciden con las plantillas: lista con contenido centrado de 880px y perfil de 820px con columna derecha de 300px. *(Computed a 1440px: lista `max-width: 880px` centrada (left 404 = 248 de sidebar + 156), perfil 820px y columna 300px. Comparación visual lado a lado contra `ninos.dc.html` y `perfil-nino.dc.html` sin diferencias: `.playwright-mcp/spec02-ref-ninos.png` vs `.playwright-mcp/spec02-app-ninos-v2.png`, `.playwright-mcp/spec02-ref-perfil.png` vs `.playwright-mcp/spec02-app-perfil.png`.)*
|
||||
- [x] Los archivos nuevos no usan atributos `style`; todo es Tailwind con valores arbitrarios exactos. *(Grep `style=` en `app/`: 0 resultados; `tsc --noEmit` y `eslint` limpios sobre los 6 archivos.)*
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Sí:** rutas `/kids` y `/kids/[slug]` — decisión explícita del usuario aunque el sidebar decía `/ninos`; se actualiza `Sidebar.tsx` (única referencia, así ningún criterio de SPEC 01 queda huérfano sin notarlo).
|
||||
- **Sí:** los 8 niños con ficha completa en `app/data/kids.ts` — el perfil es una página por slug real, no un placeholder clonado.
|
||||
- **Sí:** datos inventados para los otros 7 perfiles, pero coherentes con la lista (misma edad, misma cantidad de padres, mismas alergias); solo Mateo conserva los datos literales de la plantilla.
|
||||
- **Sí:** buscador funcional con un único componente cliente (`KidsBrowser`) — es la primera pieza interactiva del repo y queda aislada; el resto sigue siendo server components.
|
||||
- **Sí:** búsqueda solo por nombre, insensible a mayúsculas y acentos — la plantilla dice "Buscar niño…" y en español los acentos son el caso de uso obvio.
|
||||
- **Sí:** regla de badge deducida de la plantilla (sin padres → VINCULAR, con alergia → alergia, resto → chevron) — los 3 casos existen en el diseño original.
|
||||
- **Sí:** recuadro de alergias solo cuando `allergyNote` existe — la plantilla no define cómo se ve un perfil sin alergias y omitirlo es lo más fiel.
|
||||
- **Sí:** perfil compuesto directo en `page.tsx` sin componentes de un solo uso — solo se extraen `KidCard` y `KidsBrowser`, que se reutilizan o aíslan estado.
|
||||
- **Sí:** `generateStaticParams` + `notFound()` — mock estático: los 8 slugs se prerenderizan y cualquier otro slug da 404 real.
|
||||
- **No:** filtrar por sala, edad o alergia — no está en la plantilla.
|
||||
- **No:** páginas `/agregar-nino`, `/vincular-padre` ni `/resumen-dia` — specs futuras.
|
||||
|
||||
## Riesgos
|
||||
|
||||
| Riesgo | Mitigación |
|
||||
| --- | --- |
|
||||
| El buscador cliente rompe la convención server-only del repo | `KidsBrowser` aísla el estado; `KidCard` sigue siendo puro y reutilizable |
|
||||
| Búsqueda lenta o hidratación inconsistente al filtrar | Filtrado en memoria sobre 8 items; el grid se deriva del mismo array tipado |
|
||||
| Links a páginas inexistentes confunden en la verificación | Documentado en Out of scope y en criterios: 404 esperado, igual que SPEC 01 |
|
||||
|
||||
## Qué **no** está en esta spec
|
||||
|
||||
- Páginas agregar/editar niño, vincular padre y resumen del día — cada una en su propia spec.
|
||||
- Autenticación, base de datos y persistencia.
|
||||
- Responsive móvil y dark mode.
|
||||
Reference in New Issue
Block a user