'use client'; /** * PBL v2 — Hero page. * * The Hero is the first thing the learner sees when they flip to the * PBL scene. It's deliberately light on information — title, short * description, the skills they will learn, the rough shape (stage / * task counts), and a single big "开始项目" call to action. Detail * (microtask list, hints, scripts) lives in the Workspace; opening * with all of that on screen would just intimidate. * * Clicking 开始项目 enters the Workspace immediately. The chat panel * then fires the GREETING phase of the Instructor (`/api/pbl/v2/open-task`) * so the opener streams in the place where the learner will read it. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ArrowRight, CheckCircle2, Drama, GraduationCap, Layers, RotateCcw, Sparkles, Target, } from 'lucide-react'; import type { PBLProjectV2 } from '@/lib/pbl/v2/types'; import { trimmedPBLText } from '@/lib/pbl/v2/readers'; import { useStageStore } from '@/lib/store/stage'; import { buildQuizSnapshot } from '@/lib/pbl/v2/operations/runtime/quiz-snapshot'; import { hasStartedProject, resetProjectProgress } from '@/lib/pbl/v2/operations/kernel/progress'; import { transitionProjectUiPhase } from '@/lib/pbl/v2/operations/kernel/runtime-events'; import { invalidatePendingWorkspaceLaunch, isCurrentWorkspaceLaunch, prepareCurrentWorkspaceLaunchProject, } from '@/lib/pbl/v2/operations/runtime/workspace-launch'; import { useI18n } from '@/lib/hooks/use-i18n'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { computeFitScale } from './fit-scale'; const LAUNCH_BUTTON_CLASS = 'group relative flex w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-primary py-4 text-base font-semibold text-primary-foreground shadow-[0_14px_34px_-12px_hsl(var(--primary)/0.7)] ring-1 ring-inset ring-white/15 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_20px_44px_-12px_hsl(var(--primary)/0.85)] disabled:translate-y-0 disabled:opacity-70'; interface Props { readonly sceneId: string; readonly project: PBLProjectV2; readonly onProjectChange: (next: PBLProjectV2) => void; readonly onLaunchReady?: (next: PBLProjectV2) => void; /** True while a workspace stream started earlier is still in flight in the * background (the learner stepped back to the Hero mid-stream). Reset is * disabled while true: a late stream write would otherwise land on top of — * and undo — the reset. */ readonly instructorStreaming?: boolean; } export function PBLV2Hero({ sceneId, project, onProjectChange, onLaunchReady, instructorStreaming = false, }: Props) { const { t, locale } = useI18n(); // Sync `project.language` (BCP-47 fallback locale) with the user's // UI locale from the top-right language switcher. This ONLY updates // the BCP-47 fallback — `project.languageDirective` (set by the // Planner from the classroom's content-language policy) is the // authoritative content-language source and is never overwritten // here. Runs once on mount per scene; cheap idempotent. useEffect(() => { if (!locale) return; if (project.language === locale) return; onProjectChange({ ...project, language: locale }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sceneId, locale]); const instructorRole = useMemo( () => project.roles.find((r) => r.type === 'instructor'), [project.roles], ); const microtaskCount = useMemo( () => project.milestones.reduce((acc, m) => acc + m.microtasks.length, 0), [project.milestones], ); // SCENARIO ONLY. How many roleplay "acts" sit between prep and wrapup (≥1). const roleplayActCount = useMemo( () => project.milestones.filter((m) => m.scenarioStage === 'roleplay').length, [project.milestones], ); const [launching, setLaunching] = useState(false); const launchEpochRef = useRef(0); const currentSceneIdRef = useRef(sceneId); const currentProjectRef = useRef(project); currentSceneIdRef.current = sceneId; currentProjectRef.current = project; useEffect(() => { invalidatePendingWorkspaceLaunch(launchEpochRef, setLaunching); return () => { launchEpochRef.current += 1; }; }, [sceneId]); const handleStart = async () => { // Build a snapshot of the learner's prior-quiz results from // RuntimeStore so the server can fold quiz-accuracy into the // proficiency assessment before the Instructor's GREETING fires // from the Workspace chat. // Scenes from the same classroom that come BEFORE this PBL // contribute; anything after (or this scene itself) is ignored. // const epoch = ++launchEpochRef.current; const launchSceneId = sceneId; setLaunching(true); const allScenes = useStageStore.getState().scenes; const selfIdx = allScenes.findIndex((s) => s.id === sceneId); const priorScenes = selfIdx >= 0 ? allScenes.slice(0, selfIdx) : []; try { const priorQuizResults = await buildQuizSnapshot(priorScenes); if (!isCurrentWorkspaceLaunch(epoch, launchEpochRef, launchSceneId, currentSceneIdRef)) return; const ready = prepareCurrentWorkspaceLaunchProject(currentProjectRef, priorQuizResults); if (onLaunchReady) onLaunchReady(ready); else onProjectChange(ready); } finally { if (epoch === launchEpochRef.current) setLaunching(false); } }; // Resume an already-started project: no GREETING (that would duplicate // the opener and is unnecessary), just re-enter the workspace with all // progress intact. Reuse the launch path so the 1.3s reveal plays, same // as a first entry. const handleContinue = () => { const ready = transitionProjectUiPhase(project, 'workspace'); if (onLaunchReady) onLaunchReady(ready); else onProjectChange(ready); }; // Wipe progress back to a brand-new, never-played project. Stays on the // Hero (`uiPhase: 'hero'`); the button flips back to "Start project" and // the next launch replays the full first-time journey. const handleReset = () => { onProjectChange(resetProjectProgress(project)); }; const started = hasStartedProject(project); // "What you'll gain" — the Planner's structured `gains` (3-5 readable, // project-specific competency statements: abilities / awareness / // knowledge built through the project). Fall back to the single // `learningObjective` sentence for legacy projects packaged before // `gains` existed; hide entirely when neither is set. We never // back-fill from task titles — that produced unreadable fragments // ("架构可行", "场景清晰") that aren't capabilities. const plannerGains = (project.gains ?? []).map(trimmedPBLText).filter(Boolean); const learningObjective = trimmedPBLText(project.learningObjective); const gains = plannerGains.length > 0 ? plannerGains : learningObjective ? [learningObjective] : []; // SCENARIO ONLY. `project.scenario` is the single gate for role-play // projects; normal projects leave it undefined and show only the // Instructor. We surface the primary in-scene character so the learner // sees, up front, who they'll be interacting with — distinct from the // Instructor mentor. The subtitle is a short fixed tagline (mirroring // the Instructor's), NOT the character's `situation`, which is too long // for the card and gets clipped. Display-only: never gates any logic. const scenarioCharacter = project.scenario?.characters?.[0]; // Fit-to-box scaling. The Hero renders as flowed DOM inside the stage's // fixed 16:9 box (`overflow-hidden`, no scroll). At higher browser zoom the // box gets shorter than the card's natural height and the bottom (launch // button + hint) is clipped with no way to reach it — unlike a slide deck, // which stays fully visible by scaling to fit. Mirror that: measure the box // and the card and shrink the card uniformly when it would overflow. Layout // size (`offsetWidth/Height`) is unaffected by the transform, so observing // it can't feedback-loop with the scale we apply. const fitContainerRef = useRef(null); const fitContentRef = useRef(null); const [fitScale, setFitScale] = useState(1); useLayoutEffect(() => { const container = fitContainerRef.current; const content = fitContentRef.current; if (!container || !content) return; const measure = () => { const next = computeFitScale({ containerWidth: container.clientWidth, containerHeight: container.clientHeight, contentWidth: content.offsetWidth, contentHeight: content.offsetHeight, }); setFitScale((prev) => (Math.abs(prev - next) < 0.002 ? prev : next)); }; measure(); const observer = new ResizeObserver(measure); observer.observe(container); observer.observe(content); return () => observer.disconnect(); }, []); return (
{/* Centering layer: `items-center` (not the flex default `stretch`) so the card keeps its NATURAL height — otherwise it would be stretched to the box and `offsetHeight` would equal the container, defeating the fit measurement below. */}
{t('pbl.v2.hero.title')}

{project.title}

{project.description}

{gains.length > 0 && (
{t('pbl.v2.hero.youWillLearn')}
    1 ? 'grid grid-cols-1 gap-2 sm:grid-cols-2' : 'grid grid-cols-1 gap-2' } > {gains.map((gain, i) => (
  • {gain}
  • ))}
)} {/* Stage / task counts + people. Narrow boxes use two columns to avoid grid min-width overflow; wider boxes keep the single-row layout that balances the Hero. */}
{scenarioCharacter ? ( ) : ( <> } value={project.milestones.length} label={t('pbl.v2.hero.stage')} /> } value={microtaskCount} label={t('pbl.v2.hero.task')} /> )} } role={t('pbl.v2.hero.tutor')} name={instructorRole?.name ?? 'Instructor'} intro={t('pbl.v2.hero.instructorTagline')} tone="instructor" className={scenarioCharacter ? undefined : 'col-span-2 sm:col-span-1'} /> {scenarioCharacter && ( } role={t('pbl.v2.hero.scenarioCharacter')} name={scenarioCharacter.name} intro={t('pbl.v2.hero.scenarioCharacterTagline')} tone="character" /> )}
{started ? (
{t('pbl.v2.hero.resetConfirmTitle')} {t('pbl.v2.hero.resetConfirmDescription')} {t('pbl.v2.hero.resetConfirmCancel')} {t('pbl.v2.hero.resetConfirmConfirm')}
) : ( )}

{t('pbl.v2.hero.startHint', { name: instructorRole?.name ?? 'Instructor' })}

); } const META_CARD_BASE = 'flex min-w-0 flex-col gap-1.5 rounded-2xl px-3.5 py-2.5 ring-1 ring-inset transition-colors'; /** Numeric count tile (stage / task) — icon + label on top, the big * number anchored to the bottom so all cards in the row share a * baseline. */ function StatCard({ icon, value, label, }: { readonly icon: React.ReactNode; readonly value: string | number; readonly label: string; }) { return (
{icon} {label}
{value}
); } /** A "who you'll work with" card, sharing the StatCard footprint so the * whole row aligns. `instructor` tone is the always-present mentor; * `character` tone (amber, theatre-mask icon) is the SCENARIO-ONLY * role-play character, deliberately styled apart so the learner can tell * them from the mentor at a glance. */ function PersonCard({ icon, role, name, intro, tone, className, }: { readonly icon: React.ReactNode; readonly role: string; readonly name: string; readonly intro: string; readonly tone: 'instructor' | 'character'; readonly className?: string; }) { const isCharacter = tone === 'character'; return (
{icon} {role}
{name}
{intro}
); } /** SCENARIO ONLY. Replaces the "stage / task" count tiles with a meaningful * shape of the run: the fixed three acts (prep → roleplay → wrapup). When the * middle has more than one roleplay act we show the count (e.g. "模拟 ×2"); * beats stay hidden. Shares the meta-card footprint so the row aligns. */ function ScenarioFlowCard({ label, prep, roleplay, wrapup, roleplayCount, }: { readonly label: string; readonly prep: string; readonly roleplay: string; readonly wrapup: string; readonly roleplayCount: number; }) { const mid = roleplayCount > 1 ? `${roleplay} ×${roleplayCount}` : roleplay; return (
{label}
{prep} › {mid} › {wrapup}
); }