2026-08-22 00:57:16 +03:00
|
|
|
/**
|
|
|
|
|
* QuizCard — the interactive answer UI for a pending quiz/ask question.
|
|
|
|
|
*
|
|
|
|
|
* Options are BUTTONS with a custom marker: React state is the single source
|
|
|
|
|
* of truth for selection (no native radio/checkbox inputs fighting it — that
|
|
|
|
|
* desync was the v1 selection bug). One click anywhere on the row selects.
|
|
|
|
|
*
|
|
|
|
|
* Semantics mirror the terminal quiz:
|
|
|
|
|
* - single-select: exactly one of {options, Other (ask), I-don't-know (quiz)}
|
|
|
|
|
* - multi-select: toggle options; I-don't-know/Other are exclusive toggles
|
|
|
|
|
* - text mode (ask): free-form answer
|
|
|
|
|
*/
|
|
|
|
|
|
2026-08-22 01:20:14 +03:00
|
|
|
import React, { useMemo, useState } from "react";
|
2026-08-22 00:19:49 +03:00
|
|
|
import type { ClientQuestion } from "../../../src/shared/protocol.ts";
|
|
|
|
|
|
|
|
|
|
interface QuizCardProps {
|
|
|
|
|
question: ClientQuestion;
|
|
|
|
|
send: (msg: unknown) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function QuizCard({ question, send }: QuizCardProps) {
|
|
|
|
|
const multi = question.mode === "multi-select";
|
|
|
|
|
const isQuiz = question.kind === "quiz";
|
|
|
|
|
const isText = question.mode === "text";
|
|
|
|
|
|
2026-08-22 00:57:16 +03:00
|
|
|
// Selection state — the ONLY source of truth.
|
|
|
|
|
const [selected, setSelected] = useState<string[]>([]);
|
2026-08-22 00:19:49 +03:00
|
|
|
const [dontKnow, setDontKnow] = useState(false);
|
2026-08-22 00:57:16 +03:00
|
|
|
const [otherSelected, setOtherSelected] = useState(false);
|
2026-08-22 00:19:49 +03:00
|
|
|
const [other, setOther] = useState("");
|
|
|
|
|
const [text, setText] = useState("");
|
|
|
|
|
const [note, setNote] = useState("");
|
|
|
|
|
|
2026-08-22 00:57:16 +03:00
|
|
|
const pickOption = (value: string) => {
|
|
|
|
|
setDontKnow(false);
|
|
|
|
|
if (multi) {
|
|
|
|
|
setSelected((prev) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]));
|
|
|
|
|
} else {
|
|
|
|
|
setOtherSelected(false);
|
|
|
|
|
setSelected([value]);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pickOther = () => {
|
|
|
|
|
if (multi) {
|
|
|
|
|
setOtherSelected((v) => !v);
|
|
|
|
|
} else {
|
|
|
|
|
setDontKnow(false);
|
|
|
|
|
setSelected([]);
|
|
|
|
|
setOtherSelected(true);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pickDontKnow = () => {
|
|
|
|
|
if (dontKnow) {
|
|
|
|
|
setDontKnow(false);
|
|
|
|
|
} else {
|
|
|
|
|
setDontKnow(true);
|
|
|
|
|
setSelected([]);
|
|
|
|
|
setOtherSelected(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-22 00:19:49 +03:00
|
|
|
const canSubmit = useMemo(() => {
|
|
|
|
|
if (dontKnow) return true;
|
|
|
|
|
if (isText) return text.trim().length > 0;
|
2026-08-22 00:57:16 +03:00
|
|
|
if (otherSelected && other.trim()) return true;
|
|
|
|
|
return selected.length > 0;
|
|
|
|
|
}, [dontKnow, isText, text, otherSelected, other, selected]);
|
2026-08-22 00:19:49 +03:00
|
|
|
|
|
|
|
|
const submit = () => {
|
|
|
|
|
if (!canSubmit) return;
|
|
|
|
|
send({
|
|
|
|
|
type: "answer",
|
|
|
|
|
questionId: question.id,
|
2026-08-22 00:57:16 +03:00
|
|
|
dontKnow: dontKnow || undefined,
|
|
|
|
|
values: selected.length > 0 ? selected : undefined,
|
|
|
|
|
other: otherSelected && other.trim() ? other.trim() : undefined,
|
2026-08-22 00:19:49 +03:00
|
|
|
text: isText ? text.trim() : undefined,
|
|
|
|
|
note: note.trim() || undefined,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const dismiss = () => {
|
|
|
|
|
send({ type: "dismiss", questionId: question.id });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`quiz-card ${isQuiz ? "quiz" : "ask"}`}>
|
|
|
|
|
<div className="quiz-kind">{isQuiz ? "Quiz" : "Question"}</div>
|
|
|
|
|
<div className="quiz-question">{question.question}</div>
|
|
|
|
|
{question.context && <div className="quiz-context">{question.context}</div>}
|
|
|
|
|
|
|
|
|
|
{isText ? (
|
|
|
|
|
<textarea
|
|
|
|
|
className="quiz-text"
|
|
|
|
|
placeholder="Type your answer…"
|
|
|
|
|
value={text}
|
|
|
|
|
rows={3}
|
|
|
|
|
onChange={(e) => setText(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
2026-08-22 00:57:16 +03:00
|
|
|
<div className="quiz-options" role="group" aria-label="options">
|
|
|
|
|
{question.options.map((opt) => {
|
|
|
|
|
const isSelected = selected.includes(opt.value);
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={opt.value}
|
|
|
|
|
type="button"
|
|
|
|
|
className={`quiz-option ${multi ? "multi" : "single"} ${isSelected ? "selected" : ""}`}
|
|
|
|
|
role={multi ? "checkbox" : "radio"}
|
|
|
|
|
aria-checked={isSelected}
|
|
|
|
|
onClick={() => pickOption(opt.value)}
|
|
|
|
|
>
|
|
|
|
|
<span className="quiz-marker" aria-hidden="true" />
|
|
|
|
|
<span className="quiz-option-label">
|
|
|
|
|
{opt.label}
|
|
|
|
|
{opt.description && <span className="quiz-option-desc">{opt.description}</span>}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-08-22 00:19:49 +03:00
|
|
|
{!isQuiz && (
|
2026-08-22 00:57:16 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`quiz-option ${multi ? "multi" : "single"} ${otherSelected ? "selected" : ""}`}
|
|
|
|
|
role={multi ? "checkbox" : "radio"}
|
|
|
|
|
aria-checked={otherSelected}
|
|
|
|
|
onClick={pickOther}
|
|
|
|
|
>
|
|
|
|
|
<span className="quiz-marker" aria-hidden="true" />
|
2026-08-22 00:19:49 +03:00
|
|
|
<span className="quiz-option-label">Other</span>
|
2026-08-22 00:57:16 +03:00
|
|
|
</button>
|
2026-08-22 00:19:49 +03:00
|
|
|
)}
|
2026-08-22 00:57:16 +03:00
|
|
|
{otherSelected && (
|
2026-08-22 00:19:49 +03:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="quiz-other"
|
|
|
|
|
placeholder="Your custom answer…"
|
|
|
|
|
value={other}
|
2026-08-22 00:57:16 +03:00
|
|
|
autoFocus
|
2026-08-22 00:19:49 +03:00
|
|
|
onChange={(e) => setOther(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isQuiz && (
|
2026-08-22 00:57:16 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`quiz-option dont-know ${dontKnow ? "selected" : ""}`}
|
|
|
|
|
role="checkbox"
|
|
|
|
|
aria-checked={dontKnow}
|
|
|
|
|
onClick={pickDontKnow}
|
|
|
|
|
>
|
|
|
|
|
<span className="quiz-marker" aria-hidden="true" />
|
2026-08-22 00:19:49 +03:00
|
|
|
<span className="quiz-option-label">I don't know</span>
|
2026-08-22 00:57:16 +03:00
|
|
|
</button>
|
2026-08-22 00:19:49 +03:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<textarea
|
|
|
|
|
className="quiz-note"
|
|
|
|
|
placeholder="Note (optional) — what you were thinking or unsure about"
|
|
|
|
|
value={note}
|
|
|
|
|
rows={2}
|
|
|
|
|
onChange={(e) => setNote(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="quiz-actions">
|
|
|
|
|
<button className="primary" onClick={submit} disabled={!canSubmit}>
|
|
|
|
|
{dontKnow ? "I don't know" : "Submit"}
|
|
|
|
|
</button>
|
|
|
|
|
<button className="ghost" onClick={dismiss}>
|
|
|
|
|
Skip
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|