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";
|
"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 && (
|
||||||
×
|
<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>
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user