Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/product-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ model Unit {
lessons Lesson[]
practiceExercises PracticeExercise[]
socialEvents SocialEvent[]
feedback Feedback[]

@@unique([courseId, slug])
@@index([courseId, order])
Expand Down Expand Up @@ -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?
Expand All @@ -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"
*/
Expand Down Expand Up @@ -961,6 +975,10 @@ enum ProductEventName {

enum ProductSurface {
lesson
/**
* Reporte sobre una unidad completa, no sobre una lección puntual.
*/
unit
practice
playground
app
Expand Down
4 changes: 3 additions & 1 deletion src/app/app/c/[courseSlug]/u/[unitSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,7 +52,7 @@ export default async function UnitPage({ params }: PageProps) {
>
{unitComplete ? <Check className="size-6" strokeWidth={3.2} /> : unit.order}
</span>
<div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-bold uppercase tracking-[0.06em] text-subtle-foreground">
Unidad {unit.order}
</p>
Expand All @@ -64,6 +65,7 @@ export default async function UnitPage({ params }: PageProps) {
</p>
)}
</div>
<ReportDiscrepancyButton />
</div>

<h1 className="mt-5 text-balance text-[30px] font-extrabold leading-[1.1] tracking-[-0.034em] sm:text-[38px]">
Expand Down
23 changes: 21 additions & 2 deletions src/features/admin/components/triage-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const STATUSES: { value: ReportStatusInput; label: string }[] = [
{ value: "wontfix", label: "No se arregla" },
];

const FEEDBACK_KIND_LABEL: Record<string, string> = {
discrepancy: "Discrepancia con clase",
confusing: "Confuso",
idea: "Idea",
praise: "Elogio",
other: "Otro",
};

const STATUS_VARIANT: Record<string, "default" | "secondary" | "success" | "warning"> = {
open: "warning",
triaged: "default",
Expand Down Expand Up @@ -69,8 +77,19 @@ export function TriageRow({ item }: { item: TriageItem }) {
return (
<article className="space-y-3 rounded-[var(--radius-lg)] border border-border bg-card p-4">
<header className="flex flex-wrap items-center gap-2">
<Badge variant={item.kind === "bug" ? "destructive" : "info"} size="md">
{item.kind === "bug" ? "Bug de contenido" : `Feedback · ${item.feedbackKind}`}
<Badge
variant={
item.kind === "bug" || item.feedbackKind === "discrepancy"
? "destructive"
: "info"
}
size="md"
>
{item.kind === "bug"
? "Bug de contenido"
: `Feedback · ${
FEEDBACK_KIND_LABEL[item.feedbackKind ?? ""] ?? item.feedbackKind
}`}
</Badge>
<Badge variant={STATUS_VARIANT[item.status] ?? "secondary"} size="md">
{item.status}
Expand Down
23 changes: 17 additions & 6 deletions src/features/admin/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 12 additions & 7 deletions src/features/feedback/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 }> => {
Expand All @@ -56,6 +60,7 @@ export const submitFeedback = withActionErrorHandling(
message,
path: context.path,
surface: context.surface,
unitId: context.unitId,
lessonId: context.lessonId,
practiceExerciseId: context.practiceExerciseId,
},
Expand Down
86 changes: 73 additions & 13 deletions src/features/feedback/components/feedback-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -56,11 +105,25 @@ export function FeedbackDialog({
},
[isControlled, onOpenChange],
);
const [kind, setKind] = React.useState<Kind>("confusing");
const [kind, setKind] = React.useState<Kind>(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 =
Expand Down Expand Up @@ -99,11 +162,8 @@ export function FeedbackDialog({
<DialogContent>
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>¿Cómo te está yendo?</DialogTitle>
<DialogDescription>
Esto nos ayuda a decidir qué mejorar. Sabemos en qué pantalla
estás, no hace falta que lo expliques.
</DialogDescription>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>

<div
Expand Down Expand Up @@ -140,7 +200,7 @@ export function FeedbackDialog({
id="feedback-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder='Ej: "No entendí para qué sirve el for anidado en esta lección."'
placeholder={copy.placeholder}
rows={4}
maxLength={FEEDBACK_MAX_LENGTH}
aria-invalid={tooShort || undefined}
Expand Down
Loading
Loading