learning_ai_notes/web/src/components/SurveyBanner.tsx

185 lines
6.8 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { Button } from "@/components/ui/Primitives";
import { getSurveyClient } from "@/lib/survey-client";
import { toast } from "@/lib/toast";
import type { ActiveSurvey, Question, QuestionAnswer } from "@bytelyst/survey-client";
export function SurveyBanner() {
const [survey, setSurvey] = useState<ActiveSurvey | null>(null);
const [currentIdx, setCurrentIdx] = useState(0);
const [answers, setAnswers] = useState<Record<string, QuestionAnswer>>({});
const [started, setStarted] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchSurvey = useCallback(async () => {
try {
const { survey: active } = await getSurveyClient().getActiveSurvey();
if (active) setSurvey((prev) => prev ?? active);
} catch {
// non-critical
}
}, []);
useEffect(() => {
fetchSurvey();
pollRef.current = setInterval(fetchSurvey, 10 * 60_000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [fetchSurvey]);
const dismiss = useCallback(async () => {
if (!survey) return;
try { await getSurveyClient().dismissSurvey(survey.id); } catch { /* best-effort */ }
setSurvey(null);
}, [survey]);
async function handleStart() {
if (!survey) return;
try { await getSurveyClient().startSurvey(survey.id); } catch { /* best-effort */ }
setStarted(true);
}
function buildAnswer(question: Question, value: string): QuestionAnswer {
switch (question.type) {
case "rating":
case "nps":
case "scale":
return { type: "rating", value: Number(value) };
case "single_choice":
case "dropdown":
return { type: "single_choice", optionId: value };
case "multiple_choice":
return { type: "multiple_choice", optionIds: [value] };
case "ranking":
return { type: "ranking", rankedOptionIds: [value] };
default:
return { type: "text", value };
}
}
async function handleAnswer(question: Question, value: string) {
const answer = buildAnswer(question, value);
setAnswers((prev) => ({ ...prev, [question.id]: answer }));
try { await getSurveyClient().submitAnswer(survey!.id, question.id, answer); } catch { /* best-effort */ }
}
async function handleNext() {
if (!survey) return;
if (currentIdx < survey.questions.length - 1) {
setCurrentIdx((i) => i + 1);
} else {
try { await getSurveyClient().completeSurvey(survey.id); } catch { /* best-effort */ }
toast.success("Thanks for your feedback!");
setSurvey(null);
}
}
if (!survey) return null;
if (!started) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "var(--nl-space-3) var(--nl-space-4)", background: "var(--nl-success-muted)", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}>
<span><strong>{survey.title}</strong> Quick survey ({survey.questions.length} questions)</span>
<div style={{ display: "flex", gap: "var(--nl-space-3)" }}>
<Button onClick={handleStart} size="sm">Start</Button>
<Button onClick={dismiss} aria-label="Dismiss survey" variant="ghost" size="sm">&times;</Button>
</div>
</div>
);
}
const question = survey.questions[currentIdx];
if (!question) return null;
const isTextType = question.type === "text_short" || question.type === "text_long";
const isRatingType = question.type === "rating" || question.type === "nps" || question.type === "scale";
const isChoiceType = question.type === "single_choice" || question.type === "multiple_choice" || question.type === "dropdown";
const currentAnswer = answers[question.id];
const hasAnswer = !!currentAnswer;
function getTextValue(): string {
return currentAnswer?.type === "text" ? currentAnswer.value : "";
}
function getRatingValue(): number | null {
return currentAnswer?.type === "rating" || currentAnswer?.type === "nps" ? currentAnswer.value : null;
}
function getChoiceValue(): string | null {
if (currentAnswer?.type === "single_choice") return currentAnswer.optionId;
return null;
}
return (
<div style={{ padding: "var(--nl-space-4)", background: "var(--nl-success-muted)", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "var(--nl-space-3)" }}>
<strong>{question.text}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{currentIdx + 1}/{survey.questions.length}</span>
</div>
{isTextType && (
<input
className="input-shell"
placeholder="Your answer…"
value={getTextValue()}
onChange={(e) => handleAnswer(question, e.target.value)}
aria-label="Survey answer"
/>
)}
{isRatingType && (
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
{Array.from({ length: (question.maxValue ?? 5) - (question.minValue ?? 1) + 1 }, (_, i) => (question.minValue ?? 1) + i).map((n) => (
<Button
key={n}
onClick={() => handleAnswer(question, String(n))}
aria-label={`Rate ${n}`}
variant={getRatingValue() === n ? "primary" : "secondary"}
style={{
width: 36, height: 36,
border: getRatingValue() === n ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
fontWeight: 600,
}}
>
{n}
</Button>
))}
</div>
)}
{isChoiceType && question.options && (
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{question.options.map((opt) => (
<Button
key={opt.id}
onClick={() => handleAnswer(question, opt.id)}
aria-label={`Choose ${opt.text}`}
variant={getChoiceValue() === opt.id ? "primary" : "secondary"}
style={{
padding: "4px 12px",
border: getChoiceValue() === opt.id ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
}}
>
{opt.emoji ? `${opt.emoji} ` : ""}{opt.text}
</Button>
))}
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--nl-space-3)", gap: "var(--nl-space-2)" }}>
<Button onClick={dismiss} variant="ghost" size="sm">Dismiss</Button>
<Button
onClick={handleNext}
disabled={!hasAnswer}
size="sm"
>
{currentIdx < survey.questions.length - 1 ? "Next" : "Submit"}
</Button>
</div>
</div>
);
}