learning_ai_notes/web/src/components/SurveyBanner.tsx
saravanakumardb1 2408f43426 feat(web/ui5+ui7): migrate 12 components to @bytelyst/ui primitives
Finishes UI5 and kicks off UI7 by migrating the remaining form-heavy
components plus the note-detail right-rail panels. Drops legacy class
matches from 92 → 67 (-25) and raw interactive controls from 38 → 25
(-13). Ratchet baseline updated to the new floor.

Components migrated:

UI5 finish:
- NoteEditor.tsx — surface-card wrapper → Card, title input → Input,
  Tiptap editor className updated to use border + bg classes instead
  of input-shell. Toolbar buttons left as raw (intentional, tightly
  styled icon controls).
- SmartActionsPanel.tsx — result panel surface-muted → Tailwind
  bg-[var(--nl-surface-muted)] utility.
- ArtifactPanel.tsx — section→Card, badge→Badge, all three input-shell
  inputs/selects/textareas→Input/Select/Textarea, surface-muted form
  shell + per-artifact row → Tailwind bg-utility, raw <button> Open
  → Button.
- CommandPalette.tsx — surface-card command sheet → Tailwind layered
  classes, search input → Input (now ref-forwarded), kind badge → Badge.

UI7 component pass:
- MetadataPanel.tsx — section→Card, tag badge→Badge.
- LinkedNotesPanel.tsx — section→Card, surface-muted link row →
  Tailwind bg-utility with hover state.
- PalaceStats.tsx — section→Card, inline styles → Tailwind utilities.
- ExtractedTasksPanel.tsx — surface-muted row → Tailwind.
- NoteVersionsPanel.tsx — all three section/surface-card variants →
  Card + raw button → preserved (interactive disclosure).
- Pagination.tsx — raw <button> Previous/Next → Button, surface-muted
  → built-in secondary variant.
- TaskReviewPanel.tsx — full migration: section→Card, badge→Badge,
  input-shell + textarea + raw button → Input/Textarea/Button.
- SurveyBanner.tsx — survey answer input-shell → Input.

Adapter changes:
- web/src/components/ui/Primitives.tsx — Input and Textarea now use
  React.forwardRef so callers like CommandPalette can attach refs.

Verified:
- pnpm --filter @notelett/web run typecheck: passes
- pnpm --filter @notelett/web test: 96/96 still pass
- pnpm run audit:ui:ratchet: at new baseline (25/67/0/0)
- pnpm run audit:ui: legacy class matches now in dashboard / search /
  workspaces / notes-detail / palace / chat pages (UI6/UI7 page targets)
2026-05-23 01:33:48 -07:00

184 lines
6.8 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { Button, Input } 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
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>
);
}