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