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";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { getBroadcastClient } from "@/lib/broadcast-client";
interface BroadcastMessage {
id: string;
title: string;
body?: string;
type: "info" | "warning" | "announcement";
actionUrl?: string;
actionLabel?: string;
}
import type { InAppMessage } from "@bytelyst/broadcast-client";
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(() => {
const client = getBroadcastClient();
let stop: (() => void) | undefined;
async function init() {
try {
const list = await client.listMessages();
setMessages(list as BroadcastMessage[]);
stop = client.pollMessages(5 * 60_000, (updated) => {
setMessages(updated as BroadcastMessage[]);
});
} catch {
// silent — broadcast is non-critical
}
}
init();
return () => stop?.();
}, []);
fetchMessages();
intervalRef.current = setInterval(fetchMessages, 5 * 60_000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchMessages]);
async function dismiss(id: string) {
try {
@ -45,13 +34,13 @@ export function BroadcastBanner() {
setMessages((prev) => prev.filter((m) => m.id !== id));
}
async function handleClick(msg: BroadcastMessage) {
async function handleClick(msg: InAppMessage) {
try {
await getBroadcastClient().trackClick(msg.id);
} catch {
// 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;
@ -67,7 +56,7 @@ export function BroadcastBanner() {
alignItems: "center",
justifyContent: "space-between",
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)",
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 }}>
<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.actionUrl && (
{msg.ctaUrl && (
<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>
)}
</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)" }}>
&times;
</button>
{msg.dismissible !== false && (
<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)" }}>
&times;
</button>
)}
</div>
))}
</div>

View File

@ -1,49 +1,33 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { getSurveyClient } from "@/lib/survey-client";
import { toast } from "@/lib/toast";
interface SurveyQuestion {
id: string;
text: string;
type: "text" | "rating" | "choice";
options?: string[];
}
interface ActiveSurvey {
id: string;
title: string;
questions: SurveyQuestion[];
}
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, string>>({});
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(() => {
const client = getSurveyClient();
let stop: (() => void) | undefined;
async function init() {
try {
const active = await client.getActiveSurvey();
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
}, []);
fetchSurvey();
pollRef.current = setInterval(fetchSurvey, 10 * 60_000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [fetchSurvey]);
const dismiss = useCallback(async () => {
if (!survey) return;
@ -57,9 +41,28 @@ export function SurveyBanner() {
setStarted(true);
}
async function handleAnswer(questionId: string, value: string) {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
try { await getSurveyClient().submitAnswer(survey!.id, questionId, value); } catch { /* best-effort */ }
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() {
@ -90,6 +93,25 @@ export function SurveyBanner() {
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: "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)" }}>
@ -97,27 +119,27 @@ export function SurveyBanner() {
<span style={{ color: "var(--nl-text-secondary)" }}>{currentIdx + 1}/{survey.questions.length}</span>
</div>
{question.type === "text" && (
{isTextType && (
<input
className="input-shell"
placeholder="Your answer…"
value={answers[question.id] ?? ""}
onChange={(e) => handleAnswer(question.id, e.target.value)}
value={getTextValue()}
onChange={(e) => handleAnswer(question, e.target.value)}
/>
)}
{question.type === "rating" && (
{isRatingType && (
<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
key={n}
onClick={() => handleAnswer(question.id, String(n))}
onClick={() => handleAnswer(question, String(n))}
style={{
width: 36, height: 36,
borderRadius: "var(--nl-radius-sm)",
border: answers[question.id] === String(n) ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
background: answers[question.id] === String(n) ? "var(--nl-accent-primary)" : "transparent",
color: answers[question.id] === String(n) ? "#fff" : "var(--nl-text-primary)",
border: getRatingValue() === n ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
background: getRatingValue() === n ? "var(--nl-accent-primary)" : "transparent",
color: getRatingValue() === n ? "#fff" : "var(--nl-text-primary)",
cursor: "pointer", fontWeight: 600,
}}
>
@ -127,21 +149,21 @@ export function SurveyBanner() {
</div>
)}
{question.type === "choice" && question.options && (
{isChoiceType && question.options && (
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{question.options.map((opt) => (
<button
key={opt}
onClick={() => handleAnswer(question.id, opt)}
key={opt.id}
onClick={() => handleAnswer(question, opt.id)}
style={{
padding: "4px 12px",
borderRadius: "var(--nl-radius-sm)",
border: answers[question.id] === opt ? "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",
border: getChoiceValue() === opt.id ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
background: getChoiceValue() === opt.id ? "rgba(90,140,255,0.15)" : "transparent",
cursor: "pointer",
}}
>
{opt}
{opt.emoji ? `${opt.emoji} ` : ""}{opt.text}
</button>
))}
</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={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" }}
>
{currentIdx < survey.questions.length - 1 ? "Next" : "Submit"}