From 3801e3910f33e4ecf4da58435cd38ed63a14dbaa Mon Sep 17 00:00:00 2001 From: eeminionn <109454414+eeminionn@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:31:32 -0400 Subject: [PATCH] feat: guide the first classroom session --- v2/e2e/frontend-only.spec.ts | 4 + v2/e2e/mentor.spec.ts | 16 ++++ v2/e2e/mobile.spec.ts | 2 + v2/e2e/student.spec.ts | 12 +++ v2/src/components/AppShell.tsx | 26 +++++- v2/src/components/GettingStarted.tsx | 130 ++++++++++++++++++++++++++ v2/src/styles.css | 131 +++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 v2/src/components/GettingStarted.tsx diff --git a/v2/e2e/frontend-only.spec.ts b/v2/e2e/frontend-only.spec.ts index 7236e07..507b267 100644 --- a/v2/e2e/frontend-only.spec.ts +++ b/v2/e2e/frontend-only.spec.ts @@ -29,6 +29,7 @@ test("frontend sandbox keeps the current student UI without backend calls", asyn await expect(page.getByText("Sandbox local de Aula 3.0")).toBeVisible(); await page.getByRole("button", { name: "Ver interfaz de estudiante" }).click(); + await page.getByRole("button", { name: "Omitir", exact: true }).click(); await expect( page.getByText("Vista para contribuir al frontend"), ).toBeVisible(); @@ -66,6 +67,7 @@ test("frontend sandbox renders rewards while keeping redemptions disabled", asyn const backendRequests = watchBackendRequests(page); await page.goto("./"); await page.getByRole("button", { name: "Ver interfaz de estudiante" }).click(); + await page.getByRole("button", { name: "Omitir", exact: true }).click(); await page.getByRole("link", { name: "Premios" }).click(); await expect( page.getByRole("heading", { name: "Premios", exact: true }), @@ -80,6 +82,7 @@ test("frontend sandbox displays mentor controls but blocks mutations", async ({ const backendRequests = watchBackendRequests(page); await page.goto("./"); await page.getByRole("button", { name: "Ver panel del mentor" }).click(); + await page.getByRole("button", { name: "Entendido" }).click(); await expect( page.getByRole("heading", { name: "Panel de eeminionn" }), ).toBeVisible(); @@ -109,6 +112,7 @@ test("frontend sandbox remains usable on a narrow viewport", async ({ page }) => await page.setViewportSize({ width: 360, height: 800 }); await page.goto("./"); await page.getByRole("button", { name: "Ver interfaz de estudiante" }).click(); + await page.getByRole("button", { name: "Omitir", exact: true }).click(); await expect( page.getByText("Vista para contribuir al frontend"), diff --git a/v2/e2e/mentor.spec.ts b/v2/e2e/mentor.spec.ts index 704bc6f..a5af011 100644 --- a/v2/e2e/mentor.spec.ts +++ b/v2/e2e/mentor.spec.ts @@ -3,6 +3,10 @@ import { expect, test, type Page } from "@playwright/test"; async function loginAsMentor(page: Page) { await page.goto("./"); await page.getByRole("button", { name: "Entrar como eeminionn" }).click(); + const guide = page.getByRole("dialog", { name: "Lo esencial está en un solo lugar" }); + if (await guide.waitFor({ state: "visible", timeout: 2_000 }).then(() => true).catch(() => false)) { + await guide.getByRole("button", { name: "Entendido" }).click(); + } await expect( page.getByRole("heading", { name: "Panel de eeminionn" }), ).toBeVisible(); @@ -72,6 +76,16 @@ test("mentor overview prioritizes actions without duplicate navigation", async ( await expect(page.getByText("Ver más indicadores")).toBeVisible(); }); +test("mentor can reopen the classroom preparation summary", async ({ page }) => { + await loginAsMentor(page); + await page.getByRole("button", { name: "Abrir guía rápida" }).click(); + const guide = page.getByRole("dialog", { + name: "Lo esencial está en un solo lugar", + }); + await expect(guide.getByText("Hay estudiantes en el curso")).toBeVisible(); + await expect(guide.getByText("Hay al menos una tarea publicada")).toBeVisible(); +}); + test("mentor creates an assignment for selected students", async ({ page }) => { await loginAsMentor(page); await page.getByRole("link", { name: "Tareas", exact: true }).click(); @@ -93,6 +107,7 @@ test("mentor creates a reward and fulfills a student redemption", async ({ }) => { await page.goto("./"); await page.getByRole("button", { name: "Entrar como estudiante" }).click(); + await page.getByRole("button", { name: "Omitir", exact: true }).click(); await page.getByRole("link", { name: "Premios" }).click(); const reward = page.locator(".reward-card").filter({ hasText: "Pista extra" }); await reward.getByRole("button", { name: "Canjear" }).click(); @@ -104,6 +119,7 @@ test("mentor creates a reward and fulfills a student redemption", async ({ await page.getByRole("menuitem", { name: "Cerrar sesión" }).click(); await page.getByRole("button", { name: "Entrar como eeminionn" }).click(); + await page.getByRole("button", { name: "Entendido" }).click(); await page.getByRole("link", { name: "Premios", exact: true }).click(); const redemption = page .locator(".redemption-admin-row") diff --git a/v2/e2e/mobile.spec.ts b/v2/e2e/mobile.spec.ts index 9de8c4e..97625f4 100644 --- a/v2/e2e/mobile.spec.ts +++ b/v2/e2e/mobile.spec.ts @@ -5,6 +5,7 @@ test("mobile navigation and workspace remain inside the viewport", async ({ }) => { await page.goto("./"); await page.getByRole("button", { name: "Entrar como estudiante" }).click(); + await page.getByRole("button", { name: "Omitir", exact: true }).click(); await expect( page.getByRole("heading", { name: "Tienes 3 tareas pendientes" }), ).toBeVisible(); @@ -46,6 +47,7 @@ test("mobile rewards keep cards and confirmation inside the viewport", async ({ }) => { await page.goto("./"); await page.getByRole("button", { name: "Entrar como estudiante" }).click(); + await page.getByRole("button", { name: "Omitir", exact: true }).click(); await page.getByRole("button", { name: "Abrir navegación" }).click(); await page.getByRole("link", { name: "Premios" }).click(); await expect( diff --git a/v2/e2e/student.spec.ts b/v2/e2e/student.spec.ts index af7e384..5ba9135 100644 --- a/v2/e2e/student.spec.ts +++ b/v2/e2e/student.spec.ts @@ -4,6 +4,18 @@ import AxeBuilder from "@axe-core/playwright"; test.beforeEach(async ({ page }) => { await page.goto("./"); await page.getByRole("button", { name: "Entrar como estudiante" }).click(); + await page.getByRole("button", { name: "Omitir", exact: true }).click(); + await expect(page.getByRole("heading", { name: /Hola, Camila/ })).toBeVisible(); +}); + +test("student can reopen the short getting started guide", async ({ page }) => { + await page.getByRole("button", { name: "Abrir guía rápida" }).click(); + await expect(page.getByRole("heading", { name: "Abre tu próxima tarea" })).toBeVisible(); + await page.getByRole("button", { name: "Siguiente" }).click(); + await expect(page.getByRole("heading", { name: "Ejecuta antes de entregar" })).toBeVisible(); + await page.getByRole("button", { name: "Siguiente" }).click(); + await expect(page.getByRole("heading", { name: "Entrega y espera feedback" })).toBeVisible(); + await page.getByRole("button", { name: "Ir a mis tareas" }).click(); await expect(page.getByRole("heading", { name: /Hola, Camila/ })).toBeVisible(); }); diff --git a/v2/src/components/AppShell.tsx b/v2/src/components/AppShell.tsx index 7a8479c..e18a181 100644 --- a/v2/src/components/AppShell.tsx +++ b/v2/src/components/AppShell.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Bell, BookCopy, BookOpen, CalendarPlus, + CircleHelp, ChevronDown, ClipboardCheck, Eye, @@ -28,6 +29,10 @@ import { useNavigate, } from "react-router-dom"; import { ProfileAvatar } from "@/components/ProfileAvatar"; +import { + GettingStarted, + shouldOpenGettingStarted, +} from "@/components/GettingStarted"; import { getPendingReviews } from "@/models/reviews"; import { useClassroom } from "@/state/classroom-context"; @@ -68,6 +73,7 @@ export function AppShell() { const [profileOpen, setProfileOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const [selectedStudentId, setSelectedStudentId] = useState(""); + const [guideOpen, setGuideOpen] = useState(false); const location = useLocation(); const navigate = useNavigate(); const unread = @@ -84,6 +90,12 @@ export function AppShell() { snapshot?.profiles.filter((entry) => entry.role === "student") ?? []; const pendingReviews = snapshot ? getPendingReviews(snapshot).length : 0; + useEffect(() => { + if (profile && !isStudentPreview && shouldOpenGettingStarted(profile.id)) { + setGuideOpen(true); + } + }, [isStudentPreview, profile]); + return (
+ {!isStudentPreview ? ( + + ) : null} {isStudentPreview ? (
+ setGuideOpen(false)} />
); } diff --git a/v2/src/components/GettingStarted.tsx b/v2/src/components/GettingStarted.tsx new file mode 100644 index 0000000..9432f8b --- /dev/null +++ b/v2/src/components/GettingStarted.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import { + ArrowRight, + Check, + CheckCircle2, + ClipboardCheck, + Play, + Send, + Users, + X, +} from "lucide-react"; +import { useClassroom } from "@/state/classroom-context"; + +const GUIDE_VERSION = 1; + +function storageKey(userId: string) { + return `tomatin.guide.${userId}.v${GUIDE_VERSION}`; +} + +export function GettingStarted({ open, onClose }: { open: boolean; onClose: () => void }) { + const { profile, snapshot, isStudentPreview } = useClassroom(); + const [step, setStep] = useState(0); + const isStaff = profile?.role === "owner" || profile?.role === "mentor"; + + useEffect(() => { + if (open) setStep(0); + }, [open]); + + if (!open || !profile || !snapshot || isStudentPreview) return null; + + function finish() { + localStorage.setItem(storageKey(profile!.id), "done"); + onClose(); + } + + if (isStaff) { + const students = snapshot.profiles.filter((entry) => entry.role === "student"); + const published = snapshot.assignments.filter((entry) => entry.status === "published"); + const notificationReady = snapshot.githubNotifications.some( + (entry) => entry.status === "sent" || entry.status === "partial", + ); + const checks = [ + { label: "Hay estudiantes en el curso", done: students.length > 0 }, + { label: "Hay al menos una tarea publicada", done: published.length > 0 }, + { label: "Los avisos de GitHub tuvieron una prueba exitosa", done: notificationReady }, + ]; + + return ( +
+
+ + +

PREPARAR EL AULA

+

Lo esencial está en un solo lugar

+

Este resumen se actualiza solo. No necesitas configurar nada desde aquí.

+ +
+
+ +
+
+ ); + } + + const steps = [ + { + icon: ArrowRight, + title: "Abre tu próxima tarea", + body: "En Tareas siempre verás primero lo que debes resolver y cuándo vence.", + }, + { + icon: Play, + title: "Ejecuta antes de entregar", + body: "Prueba tu código. El panel de resultados te dirá qué salió bien y qué revisar.", + }, + { + icon: Send, + title: "Entrega y espera feedback", + body: "Tu profesor revisará la solución. Si pide cambios, tu código seguirá guardado.", + }, + ]; + const current = steps[step]; + const Icon = current.icon; + + return ( +
+
+ + +

PRIMEROS PASOS · {step + 1} DE {steps.length}

+

{current.title}

+

{current.body}

+
+ {steps.map((entry, index) => )} +
+
+ + +
+
+
+ ); +} + +export function shouldOpenGettingStarted(userId: string) { + return localStorage.getItem(storageKey(userId)) !== "done"; +} diff --git a/v2/src/styles.css b/v2/src/styles.css index bec2bbd..55ef72f 100644 --- a/v2/src/styles.css +++ b/v2/src/styles.css @@ -5773,6 +5773,137 @@ fieldset legend { grid-column: 1 / -1; } +.getting-started-backdrop { + z-index: 80; +} + +.getting-started { + position: relative; + width: min(460px, calc(100vw - 28px)); + padding: 30px; + border: 1px solid var(--line-strong); + border-radius: var(--radius); + background: var(--panel-strong); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55); +} + +.getting-started-close { + position: absolute; + top: 12px; + right: 12px; +} + +.getting-started-icon { + display: grid; + width: 48px; + height: 48px; + margin-bottom: 18px; + place-items: center; + border: 1px solid rgba(103, 232, 165, 0.3); + border-radius: 5px; + color: var(--green); + background: rgba(103, 232, 165, 0.08); +} + +.getting-started-icon svg { + width: 23px; +} + +.getting-started h2 { + margin: 6px 0 9px; + font-size: 23px; +} + +.getting-started > p:not(.eyebrow) { + margin: 0 0 20px; + color: var(--muted-strong); + line-height: 1.55; +} + +.guide-progress { + display: grid; + gap: 6px; + margin: 22px 0; + grid-template-columns: repeat(3, 1fr); +} + +.guide-progress span { + height: 3px; + background: var(--line-strong); +} + +.guide-progress span.is-active { + background: var(--green); +} + +.getting-started-actions { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.setup-checklist { + display: grid; + gap: 7px; + margin: 0 0 14px; + padding: 0; + list-style: none; +} + +.setup-checklist li { + display: grid; + min-height: 46px; + align-items: center; + gap: 9px; + padding: 8px 10px; + border: 1px solid var(--line); + grid-template-columns: 18px minmax(0, 1fr) auto; +} + +.setup-checklist li > svg, +.setup-checklist li > span:first-child { + width: 16px; + height: 16px; + border: 1px solid var(--line-strong); + border-radius: 50%; +} + +.setup-checklist li.is-done > svg { + border: 0; + color: var(--green); +} + +.setup-checklist strong, +.setup-checklist small { + font-size: 9px; +} + +.setup-checklist small { + color: var(--yellow); +} + +.setup-checklist .is-done small { + color: var(--green); +} + +.getting-started-tip { + display: flex; + align-items: start; + gap: 9px; + margin: 14px 0 20px; + padding: 10px; + color: var(--muted-strong); + background: rgba(101, 201, 232, 0.055); + font-size: 9px; + line-height: 1.5; +} + +.getting-started-tip svg { + width: 16px; + flex: 0 0 auto; + color: var(--cyan); +} + .rewards-header { align-items: center; }