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
4 changes: 4 additions & 0 deletions v2/e2e/frontend-only.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 }),
Expand All @@ -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();
Expand Down Expand Up @@ -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"),
Expand Down
16 changes: 16 additions & 0 deletions v2/e2e/mentor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions v2/e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions v2/e2e/student.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
26 changes: 25 additions & 1 deletion v2/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
Bell,
BookCopy,
BookOpen,
CalendarPlus,
CircleHelp,
ChevronDown,
ClipboardCheck,
Eye,
Expand All @@ -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";

Expand Down Expand Up @@ -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 =
Expand All @@ -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 (
<div className={`app-shell ${frontendOnly ? "frontend-only" : ""}`}>
<aside
Expand Down Expand Up @@ -215,6 +227,17 @@ export function AppShell() {
</span>
</div>
<div className="topbar-actions">
{!isStudentPreview ? (
<button
className="icon-button"
type="button"
aria-label="Abrir guía rápida"
title="Guía rápida"
onClick={() => setGuideOpen(true)}
>
<CircleHelp aria-hidden="true" />
</button>
) : null}
{isStudentPreview ? (
<div className="preview-indicator" role="status">
<Eye aria-hidden="true" />
Expand Down Expand Up @@ -369,6 +392,7 @@ export function AppShell() {
) : null}
<Outlet />
</div>
<GettingStarted open={guideOpen} onClose={() => setGuideOpen(false)} />
</div>
);
}
130 changes: 130 additions & 0 deletions v2/src/components/GettingStarted.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="dialog-backdrop getting-started-backdrop" role="presentation">
<section className="getting-started" role="dialog" aria-modal="true" aria-labelledby="getting-started-title">
<button className="icon-button getting-started-close" type="button" aria-label="Cerrar guía" onClick={finish}>
<X aria-hidden="true" />
</button>
<span className="getting-started-icon"><ClipboardCheck aria-hidden="true" /></span>
<p className="eyebrow">PREPARAR EL AULA</p>
<h2 id="getting-started-title">Lo esencial está en un solo lugar</h2>
<p>Este resumen se actualiza solo. No necesitas configurar nada desde aquí.</p>
<ul className="setup-checklist">
{checks.map((entry) => (
<li className={entry.done ? "is-done" : ""} key={entry.label}>
{entry.done ? <CheckCircle2 aria-hidden="true" /> : <span aria-hidden="true" />}
<strong>{entry.label}</strong>
<small>{entry.done ? "Listo" : "Pendiente"}</small>
</li>
))}
</ul>
<div className="getting-started-tip">
<Users aria-hidden="true" />
<span><strong>Tu rutina</strong> Revisa entregas, crea tareas y mira alertas desde el panel.</span>
</div>
<button className="button primary wide" type="button" onClick={finish}>
Entendido <Check aria-hidden="true" />
</button>
</section>
</div>
);
}

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 (
<div className="dialog-backdrop getting-started-backdrop" role="presentation">
<section className="getting-started" role="dialog" aria-modal="true" aria-labelledby="getting-started-title">
<button className="icon-button getting-started-close" type="button" aria-label="Omitir guía" onClick={finish}>
<X aria-hidden="true" />
</button>
<span className="getting-started-icon"><Icon aria-hidden="true" /></span>
<p className="eyebrow">PRIMEROS PASOS · {step + 1} DE {steps.length}</p>
<h2 id="getting-started-title">{current.title}</h2>
<p>{current.body}</p>
<div className="guide-progress" aria-label={`Paso ${step + 1} de ${steps.length}`}>
{steps.map((entry, index) => <span className={index <= step ? "is-active" : ""} key={entry.title} />)}
</div>
<div className="getting-started-actions">
<button className="button ghost" type="button" onClick={finish}>Omitir</button>
<button
className="button primary"
type="button"
onClick={() => step === steps.length - 1 ? finish() : setStep((value) => value + 1)}
>
{step === steps.length - 1 ? "Ir a mis tareas" : "Siguiente"}
{step === steps.length - 1 ? <Check aria-hidden="true" /> : <ArrowRight aria-hidden="true" />}
</button>
</div>
</section>
</div>
);
}

export function shouldOpenGettingStarted(userId: string) {
return localStorage.getItem(storageKey(userId)) !== "done";
}
Loading