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:
parent
fbd299d386
commit
a493e83ae4
@ -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)" }}>
|
||||
×
|
||||
</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)" }}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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"}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user