fix: correct BroadcastBanner and SurveyBanner API usage

BroadcastBanner: use {messages} destructure from listMessages(),
replace broken pollMessages callback with manual setInterval refetch,
use real InAppMessage type from @bytelyst/broadcast-client.

SurveyBanner: destructure {survey} from getActiveSurvey(), replace
broken pollSurveys callback with manual setInterval, pass typed
QuestionAnswer objects to submitAnswer() instead of raw strings,
align question type rendering with actual API types (single_choice,
rating, nps, text_short, text_long, etc).

Made-with: Cursor
This commit is contained in:
Saravana Achu Mac 2026-03-29 22:10:07 -07:00
parent fbd299d386
commit a493e83ae4
2 changed files with 105 additions and 92 deletions

View File

@ -1,40 +1,29 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { getBroadcastClient } from "@/lib/broadcast-client"; import { getBroadcastClient } from "@/lib/broadcast-client";
import type { InAppMessage } from "@bytelyst/broadcast-client";
interface BroadcastMessage {
id: string;
title: string;
body?: string;
type: "info" | "warning" | "announcement";
actionUrl?: string;
actionLabel?: string;
}
export function BroadcastBanner() { export function BroadcastBanner() {
const [messages, setMessages] = useState<BroadcastMessage[]>([]); const [messages, setMessages] = useState<InAppMessage[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchMessages = useCallback(async () => {
try {
const { messages: list } = await getBroadcastClient().listMessages();
setMessages(list.filter((m) => m.status !== "dismissed"));
} catch {
// non-critical
}
}, []);
useEffect(() => { useEffect(() => {
const client = getBroadcastClient(); fetchMessages();
let stop: (() => void) | undefined; intervalRef.current = setInterval(fetchMessages, 5 * 60_000);
return () => {
async function init() { if (intervalRef.current) clearInterval(intervalRef.current);
try { };
const list = await client.listMessages(); }, [fetchMessages]);
setMessages(list as BroadcastMessage[]);
stop = client.pollMessages(5 * 60_000, (updated) => {
setMessages(updated as BroadcastMessage[]);
});
} catch {
// silent — broadcast is non-critical
}
}
init();
return () => stop?.();
}, []);
async function dismiss(id: string) { async function dismiss(id: string) {
try { try {
@ -45,13 +34,13 @@ export function BroadcastBanner() {
setMessages((prev) => prev.filter((m) => m.id !== id)); setMessages((prev) => prev.filter((m) => m.id !== id));
} }
async function handleClick(msg: BroadcastMessage) { async function handleClick(msg: InAppMessage) {
try { try {
await getBroadcastClient().trackClick(msg.id); await getBroadcastClient().trackClick(msg.id);
} catch { } catch {
// best-effort // best-effort
} }
if (msg.actionUrl) window.open(msg.actionUrl, "_blank", "noopener"); if (msg.ctaUrl) window.open(msg.ctaUrl, "_blank", "noopener");
} }
if (messages.length === 0) return null; if (messages.length === 0) return null;
@ -67,7 +56,7 @@ export function BroadcastBanner() {
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
padding: "var(--nl-space-3) var(--nl-space-4)", padding: "var(--nl-space-3) var(--nl-space-4)",
background: msg.type === "warning" ? "rgba(255,180,70,0.12)" : "rgba(100,160,255,0.10)", background: msg.priority === "high" || msg.priority === "urgent" ? "rgba(255,180,70,0.12)" : "rgba(100,160,255,0.10)",
borderRadius: "var(--nl-radius-sm)", borderRadius: "var(--nl-radius-sm)",
fontSize: "var(--nl-fs-sm)", fontSize: "var(--nl-fs-sm)",
}} }}
@ -75,15 +64,17 @@ export function BroadcastBanner() {
<div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-3)", flex: 1, minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-3)", flex: 1, minWidth: 0 }}>
<strong style={{ whiteSpace: "nowrap" }}>{msg.title}</strong> <strong style={{ whiteSpace: "nowrap" }}>{msg.title}</strong>
{msg.body && <span style={{ color: "var(--nl-text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{msg.body}</span>} {msg.body && <span style={{ color: "var(--nl-text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{msg.body}</span>}
{msg.actionUrl && ( {msg.ctaUrl && (
<button onClick={() => handleClick(msg)} style={{ background: "none", border: "none", color: "var(--nl-accent-primary)", cursor: "pointer", fontWeight: 600, whiteSpace: "nowrap" }}> <button onClick={() => handleClick(msg)} style={{ background: "none", border: "none", color: "var(--nl-accent-primary)", cursor: "pointer", fontWeight: 600, whiteSpace: "nowrap" }}>
{msg.actionLabel ?? "Learn more"} {msg.ctaText ?? "Learn more"}
</button> </button>
)} )}
</div> </div>
<button onClick={() => dismiss(msg.id)} aria-label="Dismiss" style={{ background: "none", border: "none", color: "var(--nl-text-secondary)", cursor: "pointer", fontSize: 16, lineHeight: 1, padding: "0 0 0 var(--nl-space-3)" }}> {msg.dismissible !== false && (
&times; <button onClick={() => dismiss(msg.id)} aria-label="Dismiss" style={{ background: "none", border: "none", color: "var(--nl-text-secondary)", cursor: "pointer", fontSize: 16, lineHeight: 1, padding: "0 0 0 var(--nl-space-3)" }}>
</button> &times;
</button>
)}
</div> </div>
))} ))}
</div> </div>

View File

@ -1,49 +1,33 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { getSurveyClient } from "@/lib/survey-client"; import { getSurveyClient } from "@/lib/survey-client";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import type { ActiveSurvey, Question, QuestionAnswer } from "@bytelyst/survey-client";
interface SurveyQuestion {
id: string;
text: string;
type: "text" | "rating" | "choice";
options?: string[];
}
interface ActiveSurvey {
id: string;
title: string;
questions: SurveyQuestion[];
}
export function SurveyBanner() { export function SurveyBanner() {
const [survey, setSurvey] = useState<ActiveSurvey | null>(null); const [survey, setSurvey] = useState<ActiveSurvey | null>(null);
const [currentIdx, setCurrentIdx] = useState(0); const [currentIdx, setCurrentIdx] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({}); const [answers, setAnswers] = useState<Record<string, QuestionAnswer>>({});
const [started, setStarted] = useState(false); 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(() => { useEffect(() => {
const client = getSurveyClient(); fetchSurvey();
let stop: (() => void) | undefined; pollRef.current = setInterval(fetchSurvey, 10 * 60_000);
return () => {
async function init() { if (pollRef.current) clearInterval(pollRef.current);
try { };
const active = await client.getActiveSurvey(); }, [fetchSurvey]);
if (active) setSurvey(active as ActiveSurvey);
stop = client.pollSurveys(10 * 60_000, (s) => {
if (s && !survey) setSurvey(s as ActiveSurvey);
});
} catch {
// silent — surveys are non-critical
}
}
init();
return () => stop?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dismiss = useCallback(async () => { const dismiss = useCallback(async () => {
if (!survey) return; if (!survey) return;
@ -57,9 +41,28 @@ export function SurveyBanner() {
setStarted(true); setStarted(true);
} }
async function handleAnswer(questionId: string, value: string) { function buildAnswer(question: Question, value: string): QuestionAnswer {
setAnswers((prev) => ({ ...prev, [questionId]: value })); switch (question.type) {
try { await getSurveyClient().submitAnswer(survey!.id, questionId, value); } catch { /* best-effort */ } 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() { async function handleNext() {
@ -90,6 +93,25 @@ export function SurveyBanner() {
const question = survey.questions[currentIdx]; const question = survey.questions[currentIdx];
if (!question) return null; 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 ( return (
<div style={{ padding: "var(--nl-space-4)", background: "rgba(100,200,120,0.08)", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}> <div style={{ padding: "var(--nl-space-4)", background: "rgba(100,200,120,0.08)", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "var(--nl-space-3)" }}> <div style={{ display: "flex", justifyContent: "space-between", marginBottom: "var(--nl-space-3)" }}>
@ -97,27 +119,27 @@ export function SurveyBanner() {
<span style={{ color: "var(--nl-text-secondary)" }}>{currentIdx + 1}/{survey.questions.length}</span> <span style={{ color: "var(--nl-text-secondary)" }}>{currentIdx + 1}/{survey.questions.length}</span>
</div> </div>
{question.type === "text" && ( {isTextType && (
<input <input
className="input-shell" className="input-shell"
placeholder="Your answer…" placeholder="Your answer…"
value={answers[question.id] ?? ""} value={getTextValue()}
onChange={(e) => handleAnswer(question.id, e.target.value)} onChange={(e) => handleAnswer(question, e.target.value)}
/> />
)} )}
{question.type === "rating" && ( {isRatingType && (
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
{[1, 2, 3, 4, 5].map((n) => ( {Array.from({ length: (question.maxValue ?? 5) - (question.minValue ?? 1) + 1 }, (_, i) => (question.minValue ?? 1) + i).map((n) => (
<button <button
key={n} key={n}
onClick={() => handleAnswer(question.id, String(n))} onClick={() => handleAnswer(question, String(n))}
style={{ style={{
width: 36, height: 36, width: 36, height: 36,
borderRadius: "var(--nl-radius-sm)", borderRadius: "var(--nl-radius-sm)",
border: answers[question.id] === String(n) ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)", border: getRatingValue() === n ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
background: answers[question.id] === String(n) ? "var(--nl-accent-primary)" : "transparent", background: getRatingValue() === n ? "var(--nl-accent-primary)" : "transparent",
color: answers[question.id] === String(n) ? "#fff" : "var(--nl-text-primary)", color: getRatingValue() === n ? "#fff" : "var(--nl-text-primary)",
cursor: "pointer", fontWeight: 600, cursor: "pointer", fontWeight: 600,
}} }}
> >
@ -127,21 +149,21 @@ export function SurveyBanner() {
</div> </div>
)} )}
{question.type === "choice" && question.options && ( {isChoiceType && question.options && (
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{question.options.map((opt) => ( {question.options.map((opt) => (
<button <button
key={opt} key={opt.id}
onClick={() => handleAnswer(question.id, opt)} onClick={() => handleAnswer(question, opt.id)}
style={{ style={{
padding: "4px 12px", padding: "4px 12px",
borderRadius: "var(--nl-radius-sm)", borderRadius: "var(--nl-radius-sm)",
border: answers[question.id] === opt ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)", border: getChoiceValue() === opt.id ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
background: answers[question.id] === opt ? "rgba(var(--nl-accent-rgb, 100,160,255), 0.15)" : "transparent", background: getChoiceValue() === opt.id ? "rgba(90,140,255,0.15)" : "transparent",
cursor: "pointer", cursor: "pointer",
}} }}
> >
{opt} {opt.emoji ? `${opt.emoji} ` : ""}{opt.text}
</button> </button>
))} ))}
</div> </div>
@ -151,7 +173,7 @@ export function SurveyBanner() {
<button onClick={dismiss} style={{ background: "none", border: "none", color: "var(--nl-text-secondary)", cursor: "pointer" }}>Dismiss</button> <button onClick={dismiss} style={{ background: "none", border: "none", color: "var(--nl-text-secondary)", cursor: "pointer" }}>Dismiss</button>
<button <button
onClick={handleNext} onClick={handleNext}
disabled={!answers[question.id]} disabled={!hasAnswer}
style={{ padding: "4px 14px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, cursor: "pointer" }} style={{ padding: "4px 14px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, cursor: "pointer" }}
> >
{currentIdx < survey.questions.length - 1 ? "Next" : "Submit"} {currentIdx < survey.questions.length - 1 ? "Next" : "Submit"}