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)
184 lines
6.8 KiB
TypeScript
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">×</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>
|
|
);
|
|
}
|