diff --git a/docs/product-analytics.md b/docs/product-analytics.md
index c646fe2..0ba5c05 100644
--- a/docs/product-analytics.md
+++ b/docs/product-analytics.md
@@ -340,10 +340,24 @@ Borrar la cuenta borra sus eventos, sesiones y pistas por `ON DELETE CASCADE`.
| | `BugReport` | `Feedback` |
|---|---|---|
-| Para qué | Contenido roto: typo, test mal configurado | Experiencia: "me confundió", idea, algo que gustó |
+| Para qué | Contenido roto: typo, test mal configurado | Experiencia: discrepancia con la clase, "me confundió", idea, algo que gustó |
| Target | Paso, reto o ejercicio de práctica (elegido por el alumno) | Ruta, superficie y recurso **derivados por el servidor** |
| Estados | `open` → `triaged` → `resolved` / `duplicate` / `wontfix` | los mismos (`ReportStatus`) |
+`Feedback.kind` (`FeedbackKind`): `discrepancy` (el contenido no corresponde
+con lo que el alumno vio en su clase, o una lección/unidad le pareció mala),
+`confusing`, `idea`, `praise`, `other`. El botón dedicado de discrepancia
+vive junto al de `BugReport` en la cabecera de la lección, y en la página de
+unidad — el alumno lo ve donde nota el problema, sin buscar un menú aparte.
+
+`Feedback.surface` incluye `unit` cuando el reporte es sobre una unidad
+completa (`unitId` set, `lessonId` null) en vez de una lección puntual;
+ambos IDs se derivan de la ruta contra la BD, nunca del cliente.
+
+Ninguno de los dos crea un issue de GitHub — la mayoría de los alumnos no
+sabe qué es GitHub y no lo usaría. Todo cae en la cola de triage interna; un
+admin decide si vale la pena normalizarlo como issue real (`issueUrl`).
+
Ambos guardan evidencia del cierre: `resolutionNote`, `issueUrl`, `prUrl`,
`triagedAt`, `resolvedAt`, `handledById`.
diff --git a/prisma/migrations/20260905000000_add_discrepancy_reports/migration.sql b/prisma/migrations/20260905000000_add_discrepancy_reports/migration.sql
new file mode 100644
index 0000000..09214fd
--- /dev/null
+++ b/prisma/migrations/20260905000000_add_discrepancy_reports/migration.sql
@@ -0,0 +1,14 @@
+-- Reportar discrepancias con la clase real (o "mala sesión/unidad") desde
+-- el mismo widget de Feedback, sin pasar por GitHub.
+--
+-- ALTER TYPE ... ADD VALUE es seguro (idempotente con IF NOT EXISTS) y
+-- ningún valor nuevo se usa en esta misma migración.
+ALTER TYPE "FeedbackKind" ADD VALUE IF NOT EXISTS 'discrepancy';
+ALTER TYPE "ProductSurface" ADD VALUE IF NOT EXISTS 'unit';
+
+-- Reporte a nivel de unidad completa (no de una lección puntual).
+ALTER TABLE "feedback" ADD COLUMN "unitId" TEXT;
+
+ALTER TABLE "feedback"
+ ADD CONSTRAINT "feedback_unitId_fkey"
+ FOREIGN KEY ("unitId") REFERENCES "unit"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 6a25abc..ad13f3c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -249,6 +249,7 @@ model Unit {
lessons Lesson[]
practiceExercises PracticeExercise[]
socialEvents SocialEvent[]
+ feedback Feedback[]
@@unique([courseId, slug])
@@index([courseId, order])
@@ -809,6 +810,14 @@ model Feedback {
*/
path String?
surface ProductSurface?
+ /**
+ * Presente cuando el reporte es sobre una unidad completa ("mala sesión",
+ * contenido que no corresponde con la clase) y no sobre una lección
+ * puntual. Mutuamente exclusivo con `lessonId` en la práctica, aunque no
+ * hay CHECK constraint: el servidor decide cuál llenar según la ruta.
+ */
+ unitId String?
+ unit Unit? @relation(fields: [unitId], references: [id], onDelete: SetNull)
lessonId String?
lesson Lesson? @relation(fields: [lessonId], references: [id], onDelete: SetNull)
practiceExerciseId String?
@@ -830,6 +839,11 @@ model Feedback {
}
enum FeedbackKind {
+ /**
+ * "esto no corresponde con lo que vi en mi clase" — discrepancia entre el
+ * contenido y la clase real, o "esta fue una mala unidad/lección".
+ */
+ discrepancy
/**
* "no entendí esto"
*/
@@ -961,6 +975,10 @@ enum ProductEventName {
enum ProductSurface {
lesson
+ /**
+ * Reporte sobre una unidad completa, no sobre una lección puntual.
+ */
+ unit
practice
playground
app
diff --git a/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx b/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx
index 51509c1..25041ff 100644
--- a/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx
+++ b/src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx
@@ -4,6 +4,7 @@ import { ArrowRight, Check } from "lucide-react";
import { BrickRow } from "@/components/ui/bricks";
import { Button } from "@/components/ui/button";
+import { ReportDiscrepancyButton } from "@/features/feedback/components/report-discrepancy-button";
import { RoadmapLessons } from "@/features/roadmap/components/roadmap-lessons";
import { getCourseBySlug } from "@/features/roadmap/queries";
import { getUnitBySlug } from "@/features/lessons/queries";
@@ -51,7 +52,7 @@ export default async function UnitPage({ params }: PageProps) {
>
{unitComplete ? : unit.order}
-
+
Unidad {unit.order}
@@ -64,6 +65,7 @@ export default async function UnitPage({ params }: PageProps) {
)}
+
diff --git a/src/features/admin/components/triage-row.tsx b/src/features/admin/components/triage-row.tsx
index a8ddf69..0440a80 100644
--- a/src/features/admin/components/triage-row.tsx
+++ b/src/features/admin/components/triage-row.tsx
@@ -23,6 +23,14 @@ const STATUSES: { value: ReportStatusInput; label: string }[] = [
{ value: "wontfix", label: "No se arregla" },
];
+const FEEDBACK_KIND_LABEL: Record = {
+ discrepancy: "Discrepancia con clase",
+ confusing: "Confuso",
+ idea: "Idea",
+ praise: "Elogio",
+ other: "Otro",
+};
+
const STATUS_VARIANT: Record = {
open: "warning",
triaged: "default",
@@ -69,8 +77,19 @@ export function TriageRow({ item }: { item: TriageItem }) {
return (
-
- {item.kind === "bug" ? "Bug de contenido" : `Feedback · ${item.feedbackKind}`}
+
+ {item.kind === "bug"
+ ? "Bug de contenido"
+ : `Feedback · ${
+ FEEDBACK_KIND_LABEL[item.feedbackKind ?? ""] ?? item.feedbackKind
+ }`}
{item.status}
diff --git a/src/features/admin/queries.ts b/src/features/admin/queries.ts
index 53fbf2b..81e21e3 100644
--- a/src/features/admin/queries.ts
+++ b/src/features/admin/queries.ts
@@ -111,6 +111,13 @@ export async function getTriageQueue(options: {
issueUrl: true,
prUrl: true,
user: { select: { username: true } },
+ unit: {
+ select: {
+ title: true,
+ slug: true,
+ course: { select: { slug: true } },
+ },
+ },
lesson: {
select: {
title: true,
@@ -162,14 +169,18 @@ export async function getTriageQueue(options: {
const feedbackItems: TriageItem[] = feedback.map((item) => {
const target = item.lesson
? `Lección: ${item.lesson.title}`
- : item.practiceExercise
- ? `Práctica: ${item.practiceExercise.title}`
- : (item.path ?? "Sin contexto");
+ : item.unit
+ ? `Unidad: ${item.unit.title}`
+ : item.practiceExercise
+ ? `Práctica: ${item.practiceExercise.title}`
+ : (item.path ?? "Sin contexto");
const href = item.lesson
? `/app/c/${item.lesson.unit.course.slug}/u/${item.lesson.unit.slug}/${item.lesson.slug}`
- : item.practiceExercise
- ? `/app/c/${item.practiceExercise.course.slug}/ejercicios/${item.practiceExercise.slug}`
- : item.path;
+ : item.unit
+ ? `/app/c/${item.unit.course.slug}/u/${item.unit.slug}`
+ : item.practiceExercise
+ ? `/app/c/${item.practiceExercise.course.slug}/ejercicios/${item.practiceExercise.slug}`
+ : item.path;
return {
id: item.id,
kind: "feedback",
diff --git a/src/features/feedback/actions.ts b/src/features/feedback/actions.ts
index c50c60f..9df2633 100644
--- a/src/features/feedback/actions.ts
+++ b/src/features/feedback/actions.ts
@@ -11,18 +11,22 @@ import { parseOrThrow } from "@/lib/validation";
import { FEEDBACK_MAX_LENGTH, resolveFeedbackContext } from "./context";
/**
- * Feedback general: "esto me confundió", "estaría bueno que...", "me gustó".
+ * Feedback general: "esto no corresponde con mi clase", "esto me confundió",
+ * "estaría bueno que...", "me gustó".
*
* NO reemplaza a `BugReport`, que sigue siendo para contenido roto y apunta a
- * un paso/ejercicio concreto. Aquí el contexto (ruta, superficie, recurso) lo
- * DERIVA EL SERVIDOR de la ruta: al alumno no se le pide que explique dónde
- * estaba, y tampoco se le cree ciegamente lo que manda.
+ * un paso/ejercicio concreto. Aquí el contexto (ruta, superficie, recurso —
+ * incluida la unidad completa cuando el reporte es sobre una) lo DERIVA EL
+ * SERVIDOR de la ruta: al alumno no se le pide que explique dónde estaba, y
+ * tampoco se le cree ciegamente lo que manda.
*
* Lo único que se guarda del usuario es su mensaje y su id. Nada de user
- * agent, IP, resolución de pantalla ni contenido del editor.
+ * agent, IP, resolución de pantalla ni contenido del editor. Nada de esto
+ * crea un issue de GitHub: cae en la cola de triage interna
+ * (`/app/admin/reportes`) para que un admin lo normalice si vale la pena.
*/
const feedbackSchema = z.object({
- kind: z.enum(["confusing", "idea", "praise", "other"]),
+ kind: z.enum(["discrepancy", "confusing", "idea", "praise", "other"]),
message: z
.string()
.trim()
@@ -38,7 +42,7 @@ const feedbackSchema = z.object({
export const submitFeedback = withActionErrorHandling(
"submitFeedback",
async (input: {
- kind: "confusing" | "idea" | "praise" | "other";
+ kind: "discrepancy" | "confusing" | "idea" | "praise" | "other";
message: string;
path?: string;
}): Promise<{ ok: true }> => {
@@ -56,6 +60,7 @@ export const submitFeedback = withActionErrorHandling(
message,
path: context.path,
surface: context.surface,
+ unitId: context.unitId,
lessonId: context.lessonId,
practiceExerciseId: context.practiceExerciseId,
},
diff --git a/src/features/feedback/components/feedback-dialog.tsx b/src/features/feedback/components/feedback-dialog.tsx
index 7182a0e..6b34416 100644
--- a/src/features/feedback/components/feedback-dialog.tsx
+++ b/src/features/feedback/components/feedback-dialog.tsx
@@ -2,7 +2,14 @@
import * as React from "react";
import { usePathname } from "next/navigation";
-import { Lightbulb, MessageSquarePlus, Send, Sparkles, HelpCircle } from "lucide-react";
+import {
+ AlertTriangle,
+ Lightbulb,
+ MessageSquarePlus,
+ Send,
+ Sparkles,
+ HelpCircle,
+} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -20,31 +27,73 @@ import { submitFeedback } from "@/features/feedback/actions";
import { FEEDBACK_MAX_LENGTH } from "@/features/feedback/context";
import { cn } from "@/lib/utils";
-type Kind = "confusing" | "idea" | "praise";
+type Kind = "discrepancy" | "confusing" | "idea" | "praise";
const KINDS: { value: Kind; label: string; icon: typeof HelpCircle }[] = [
+ {
+ value: "discrepancy",
+ label: "No corresponde con mi clase",
+ icon: AlertTriangle,
+ },
{ value: "confusing", label: "Algo me confundió", icon: HelpCircle },
{ value: "idea", label: "Tengo una idea", icon: Lightbulb },
{ value: "praise", label: "Algo me gustó", icon: Sparkles },
];
+const COPY: Record<
+ Kind,
+ { title: string; description: string; placeholder: string }
+> = {
+ discrepancy: {
+ title: "Reportar una discrepancia",
+ description:
+ '¿Esta lección o unidad no corresponde con lo que viste en tu clase, o te pareció una mala sesión? Cuéntanos qué no cuadra — ya sabemos en qué pantalla estás, no hace falta que lo expliques.',
+ placeholder:
+ 'Ej: "En mi clase vimos primero los arreglos y aquí aparecen hasta después de POO."',
+ },
+ confusing: {
+ title: "¿Cómo te está yendo?",
+ description:
+ "Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla estás, no hace falta que lo expliques.",
+ placeholder: 'Ej: "No entendí para qué sirve el for anidado en esta lección."',
+ },
+ idea: {
+ title: "¿Cómo te está yendo?",
+ description:
+ "Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla estás, no hace falta que lo expliques.",
+ placeholder: 'Ej: "Estaría bueno tener modo oscuro en el editor."',
+ },
+ praise: {
+ title: "¿Cómo te está yendo?",
+ description:
+ "Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla estás, no hace falta que lo expliques.",
+ placeholder: 'Ej: "Me encantó cómo explicaron los punteros."',
+ },
+};
+
/**
- * Feedback general sobre la experiencia. El contexto (dónde estaba el
- * alumno) viaja solo: mandamos la ruta y el servidor la interpreta. No le
- * pedimos que explique en qué pantalla estaba.
+ * Feedback general sobre la experiencia — incluye reportar que el contenido
+ * NO corresponde con la clase real, o que una lección/unidad fue mala. El
+ * contexto (dónde estaba el alumno) viaja solo: mandamos la ruta y el
+ * servidor la interpreta. No le pedimos que explique en qué pantalla estaba.
*
* Para contenido roto (typo, test mal configurado) existe `ReportBugDialog`,
- * que sí apunta a un paso/ejercicio concreto.
+ * que sí apunta a un paso/ejercicio concreto. Ninguno de los dos crea un
+ * issue de GitHub: caen en la cola de triage interna (`/app/admin/reportes`)
+ * y un admin decide si vale la pena abrir uno.
*/
export function FeedbackDialog({
children,
open: controlledOpen,
onOpenChange,
+ defaultKind = "confusing",
}: {
children?: React.ReactNode;
/** Modo controlado (para abrirlo desde un menú, que se cierra al elegir). */
open?: boolean;
onOpenChange?: (open: boolean) => void;
+ /** Qué opción viene preseleccionada al abrir (p. ej. "discrepancy" desde un botón dedicado). */
+ defaultKind?: Kind;
}) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const isControlled = controlledOpen !== undefined;
@@ -56,11 +105,25 @@ export function FeedbackDialog({
},
[isControlled, onOpenChange],
);
- const [kind, setKind] = React.useState("confusing");
+ const [kind, setKind] = React.useState(defaultKind);
const [message, setMessage] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const pathname = usePathname();
+ // Reabrir con la opción correcta preseleccionada: el diálogo del rail y el
+ // de "reportar discrepancia" comparten componente pero no instancia de
+ // estado si el caller lo re-monta, así que al reabrir alineamos con lo que
+ // pidió el trigger en vez de arrastrar la última selección. Ajuste en
+ // render (no en un efecto) siguiendo el patrón de React para "resetear
+ // estado cuando cambia un prop".
+ const [wasOpen, setWasOpen] = React.useState(open);
+ if (open !== wasOpen) {
+ setWasOpen(open);
+ if (open) setKind(defaultKind);
+ }
+
+ const copy = COPY[kind];
+
const trimmed = message.trim();
const tooShort = trimmed.length > 0 && trimmed.length < 5;
const canSubmit =
@@ -99,11 +162,8 @@ export function FeedbackDialog({