mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: add in-product survey system (#2008)
* feat: add in-product survey system
- SurveyManager: event-based trigger, Space API communication
- Trigger on first successful non-WebSocket response
- Backend API: /api/v1/survey/{pending,respond,dismiss}
- Frontend: floating survey widget with progressive questions
- Flat radio/checkbox style (not dropdown Select)
* fix: persist triggered survey events to disk across restarts
Store triggered events in data/survey_triggered_events.json so that
restarting the process doesn't re-query Space for already-triggered events.
* fix: use metadata table for survey event persistence instead of file
Store triggered events in the existing metadata KV table
(key='survey_triggered_events') instead of a standalone JSON file.
* fix: ruff format and prettier fixes
This commit is contained in:
47
src/langbot/pkg/api/http/controller/groups/survey.py
Normal file
47
src/langbot/pkg/api/http/controller/groups/survey.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('survey', '/api/v1/survey')
|
||||
class SurveyRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/pending', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _get_pending() -> str:
|
||||
"""Get pending survey for the frontend to display."""
|
||||
survey = self.ap.survey.get_pending_survey() if self.ap.survey else None
|
||||
return self.success(data={'survey': survey})
|
||||
|
||||
@self.route('/respond', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _respond() -> str:
|
||||
"""Submit survey response."""
|
||||
json_data = await quart.request.json
|
||||
survey_id = json_data.get('survey_id')
|
||||
answers = json_data.get('answers', {})
|
||||
completed = json_data.get('completed', True)
|
||||
|
||||
if not survey_id:
|
||||
return self.fail(1, 'survey_id required')
|
||||
|
||||
if self.ap.survey:
|
||||
ok = await self.ap.survey.submit_response(survey_id, answers, completed)
|
||||
if ok:
|
||||
return self.success()
|
||||
return self.fail(2, 'Failed to submit response')
|
||||
return self.fail(3, 'Survey not available')
|
||||
|
||||
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _dismiss() -> str:
|
||||
"""Dismiss survey."""
|
||||
json_data = await quart.request.json
|
||||
survey_id = json_data.get('survey_id')
|
||||
|
||||
if not survey_id:
|
||||
return self.fail(1, 'survey_id required')
|
||||
|
||||
if self.ap.survey:
|
||||
ok = await self.ap.survey.dismiss_survey(survey_id)
|
||||
if ok:
|
||||
return self.success()
|
||||
return self.fail(2, 'Failed to dismiss')
|
||||
return self.fail(3, 'Survey not available')
|
||||
@@ -39,6 +39,7 @@ from . import entities as core_entities
|
||||
from ..rag.knowledge import kbmgr as rag_mgr
|
||||
from ..vector import mgr as vectordb_mgr
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
from ..survey import manager as survey_module
|
||||
|
||||
|
||||
class Application:
|
||||
@@ -147,6 +148,8 @@ class Application:
|
||||
|
||||
telemetry: telemetry_module.TelemetryManager = None
|
||||
|
||||
survey: survey_module.SurveyManager = None
|
||||
|
||||
monitoring_service: monitoring_service.MonitoringService = None
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -34,6 +34,7 @@ from ...utils import logcache
|
||||
from ...vector import mgr as vectordb_mgr
|
||||
from .. import taskmgr
|
||||
from ...telemetry import telemetry as telemetry_module
|
||||
from ...survey import manager as survey_module
|
||||
|
||||
|
||||
@stage.stage_class('BuildAppStage')
|
||||
@@ -110,6 +111,11 @@ class BuildAppStage(stage.BootingStage):
|
||||
await telemetry_inst.initialize()
|
||||
ap.telemetry = telemetry_inst
|
||||
|
||||
# Survey manager
|
||||
survey_inst = survey_module.SurveyManager(ap)
|
||||
await survey_inst.initialize()
|
||||
ap.survey = survey_inst
|
||||
|
||||
cmd_mgr_inst = cmdmgr.CommandManager(ap)
|
||||
await cmd_mgr_inst.initialize()
|
||||
ap.cmd_mgr = cmd_mgr_inst
|
||||
|
||||
@@ -200,6 +200,11 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
# Send telemetry asynchronously and do not block pipeline via app's telemetry manager
|
||||
await self.ap.telemetry.start_send_task(payload)
|
||||
|
||||
# Trigger survey event on first successful non-WebSocket response
|
||||
if not locals().get('error_info') and adapter_name and 'WebSocket' not in adapter_name:
|
||||
if self.ap.survey:
|
||||
await self.ap.survey.trigger_event('first_bot_response_success')
|
||||
except Exception as ex:
|
||||
# Ensure telemetry issues do not affect normal flow
|
||||
self.ap.logger.warning(f'Failed to send telemetry: {ex}')
|
||||
|
||||
1
src/langbot/pkg/survey/__init__.py
Normal file
1
src/langbot/pkg/survey/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Survey module for in-product surveys triggered by events."""
|
||||
148
src/langbot/pkg/survey/manager.py
Normal file
148
src/langbot/pkg/survey/manager.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Survey manager: tracks events, communicates with Space to fetch/submit surveys."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import typing
|
||||
import httpx
|
||||
import sqlalchemy
|
||||
|
||||
from ..core import app as core_app
|
||||
from ..entity.persistence.metadata import Metadata
|
||||
from ..utils import constants
|
||||
|
||||
SURVEY_TRIGGERED_KEY = 'survey_triggered_events'
|
||||
|
||||
|
||||
class SurveyManager:
|
||||
"""Manages survey lifecycle: event tracking, pending survey fetch, submission."""
|
||||
|
||||
def __init__(self, ap: core_app.Application):
|
||||
self.ap = ap
|
||||
self._triggered_events: set[str] = set()
|
||||
self._pending_survey: typing.Optional[dict] = None
|
||||
self._space_url: str = ''
|
||||
|
||||
async def initialize(self):
|
||||
space_config = self.ap.instance_config.data.get('space', {})
|
||||
self._space_url = space_config.get('url', '').rstrip('/')
|
||||
await self._load_triggered_events()
|
||||
|
||||
async def _load_triggered_events(self):
|
||||
"""Load previously triggered events from metadata table."""
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)
|
||||
)
|
||||
row = result.first()
|
||||
if row:
|
||||
self._triggered_events = set(json.loads(row[0].value))
|
||||
except Exception:
|
||||
self._triggered_events = set()
|
||||
|
||||
async def _save_triggered_events(self):
|
||||
"""Persist triggered events to metadata table."""
|
||||
try:
|
||||
value = json.dumps(list(self._triggered_events))
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY)
|
||||
)
|
||||
if result.first():
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(Metadata).where(Metadata.key == SURVEY_TRIGGERED_KEY).values(value=value)
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(Metadata).values(key=SURVEY_TRIGGERED_KEY, value=value)
|
||||
)
|
||||
except Exception as e:
|
||||
self.ap.logger.debug(f'Failed to save survey triggered events: {e}')
|
||||
|
||||
def _is_space_configured(self) -> bool:
|
||||
space_config = self.ap.instance_config.data.get('space', {})
|
||||
if space_config.get('disable_telemetry', False):
|
||||
return False
|
||||
return bool(self._space_url)
|
||||
|
||||
async def trigger_event(self, event: str):
|
||||
"""Called when an event occurs. Checks Space for a pending survey."""
|
||||
if event in self._triggered_events:
|
||||
return
|
||||
if not self._is_space_configured():
|
||||
return
|
||||
|
||||
self._triggered_events.add(event)
|
||||
await self._save_triggered_events()
|
||||
|
||||
# Check for pending survey asynchronously
|
||||
asyncio.create_task(self._fetch_pending_survey(event))
|
||||
|
||||
async def _fetch_pending_survey(self, event: str):
|
||||
"""Fetch pending survey from Space for this event."""
|
||||
try:
|
||||
url = f'{self._space_url}/api/v1/survey/pending'
|
||||
payload = {
|
||||
'instance_id': constants.instance_id,
|
||||
'event': event,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get('code') == 0 and data.get('data', {}).get('survey'):
|
||||
self._pending_survey = data['data']['survey']
|
||||
self.ap.logger.info(f'Survey pending: {self._pending_survey.get("survey_id")}')
|
||||
except Exception as e:
|
||||
self.ap.logger.debug(f'Failed to fetch pending survey: {e}')
|
||||
|
||||
def get_pending_survey(self) -> typing.Optional[dict]:
|
||||
"""Return the current pending survey (if any) for the frontend to display."""
|
||||
return self._pending_survey
|
||||
|
||||
def clear_pending_survey(self):
|
||||
"""Clear the pending survey (after user responds or dismisses)."""
|
||||
self._pending_survey = None
|
||||
|
||||
async def submit_response(self, survey_id: str, answers: dict, completed: bool = True) -> bool:
|
||||
"""Submit a survey response to Space."""
|
||||
if not self._is_space_configured():
|
||||
return False
|
||||
try:
|
||||
url = f'{self._space_url}/api/v1/survey/respond'
|
||||
payload = {
|
||||
'survey_id': survey_id,
|
||||
'instance_id': constants.instance_id,
|
||||
'answers': answers,
|
||||
'metadata': {
|
||||
'version': constants.semantic_version,
|
||||
},
|
||||
'completed': completed,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
if resp.status_code == 200:
|
||||
self.clear_pending_survey()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to submit survey response: {e}')
|
||||
return False
|
||||
|
||||
async def dismiss_survey(self, survey_id: str) -> bool:
|
||||
"""Dismiss a survey."""
|
||||
if not self._is_space_configured():
|
||||
return False
|
||||
try:
|
||||
url = f'{self._space_url}/api/v1/survey/dismiss'
|
||||
payload = {
|
||||
'survey_id': survey_id,
|
||||
'instance_id': constants.instance_id,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10)) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
if resp.status_code == 200:
|
||||
self.clear_pending_survey()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to dismiss survey: {e}')
|
||||
return False
|
||||
391
web/src/app/home/components/survey/SurveyWidget.tsx
Normal file
391
web/src/app/home/components/survey/SurveyWidget.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type {
|
||||
SurveyQuestion,
|
||||
SurveyOption,
|
||||
} from '@/app/infra/http/BackendClient';
|
||||
import { X, ChevronRight, ChevronLeft, MessageSquare } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
/**
|
||||
* Get i18n text from a Record<string, string> based on browser locale.
|
||||
*/
|
||||
function getI18nText(obj?: Record<string, string> | null): string {
|
||||
if (!obj) return '';
|
||||
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en';
|
||||
if (lang.startsWith('zh'))
|
||||
return obj['zh_Hans'] || obj['en_US'] || Object.values(obj)[0] || '';
|
||||
if (lang.startsWith('ja'))
|
||||
return obj['ja_JP'] || obj['en_US'] || Object.values(obj)[0] || '';
|
||||
return obj['en_US'] || Object.values(obj)[0] || '';
|
||||
}
|
||||
|
||||
interface SurveyData {
|
||||
survey_id: string;
|
||||
version: number;
|
||||
title: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
questions: SurveyQuestion[];
|
||||
}
|
||||
|
||||
export default function SurveyWidget() {
|
||||
const [survey, setSurvey] = useState<SurveyData | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, unknown>>({});
|
||||
const [otherInputs, setOtherInputs] = useState<Record<string, string>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Poll for pending survey
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
let cancelled = false;
|
||||
|
||||
const checkSurvey = async () => {
|
||||
try {
|
||||
const resp = await httpClient.getSurveyPending();
|
||||
if (!cancelled && resp?.survey) {
|
||||
setSurvey(resp.survey);
|
||||
setVisible(true);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Check after 5 seconds, then every 60 seconds
|
||||
timer = setTimeout(() => {
|
||||
checkSurvey();
|
||||
timer = setInterval(checkSurvey, 60000) as unknown as NodeJS.Timeout;
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback(async () => {
|
||||
if (survey) {
|
||||
try {
|
||||
await httpClient.dismissSurvey(survey.survey_id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setVisible(false);
|
||||
}, [survey]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!survey) return;
|
||||
|
||||
// Merge "other" text inputs into answers
|
||||
const finalAnswers = { ...answers };
|
||||
for (const [qId, text] of Object.entries(otherInputs)) {
|
||||
if (text.trim()) {
|
||||
const current = finalAnswers[qId];
|
||||
if (Array.isArray(current)) {
|
||||
// Replace 'other' with the text
|
||||
finalAnswers[qId] = (current as string[]).map((v) =>
|
||||
v === 'other' ? `other:${text}` : v,
|
||||
);
|
||||
} else if (current === 'other') {
|
||||
finalAnswers[qId] = `other:${text}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await httpClient.submitSurveyResponse(
|
||||
survey.survey_id,
|
||||
finalAnswers,
|
||||
true,
|
||||
);
|
||||
setSubmitted(true);
|
||||
setTimeout(() => setVisible(false), 2000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [survey, answers, otherInputs]);
|
||||
|
||||
const setAnswer = useCallback((qId: string, value: unknown) => {
|
||||
setAnswers((prev) => ({ ...prev, [qId]: value }));
|
||||
}, []);
|
||||
|
||||
if (!visible || !survey) return null;
|
||||
|
||||
const questions = survey.questions || [];
|
||||
const totalSteps = questions.length;
|
||||
const currentQuestion = questions[currentStep];
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 w-80 bg-card border rounded-xl shadow-lg p-6 animate-in slide-in-from-bottom-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">🎉</div>
|
||||
<p className="text-sm font-medium">
|
||||
{getI18nText({
|
||||
zh_Hans: '感谢你的反馈!',
|
||||
en_US: 'Thanks for your feedback!',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="fixed bottom-6 right-6 z-50 w-12 h-12 bg-primary text-primary-foreground rounded-full shadow-lg flex items-center justify-center hover:scale-105 transition-transform"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 w-[340px] bg-card border rounded-xl shadow-lg animate-in slide-in-from-bottom-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-medium">
|
||||
{getI18nText(survey.title)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="flex gap-1">
|
||||
{questions.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||
i <= currentStep ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
{currentStep + 1} / {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Question */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-medium mb-1">
|
||||
{getI18nText(currentQuestion?.title)}
|
||||
</p>
|
||||
{currentQuestion?.subtitle && (
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
{getI18nText(currentQuestion.subtitle)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 max-h-[260px] overflow-y-auto">
|
||||
{currentQuestion?.type === 'single_select' &&
|
||||
currentQuestion.options && (
|
||||
<SingleSelectField
|
||||
options={currentQuestion.options}
|
||||
value={answers[currentQuestion.id] as string}
|
||||
onChange={(v) => setAnswer(currentQuestion.id, v)}
|
||||
otherText={otherInputs[currentQuestion.id] || ''}
|
||||
onOtherChange={(t) =>
|
||||
setOtherInputs((prev) => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: t,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentQuestion?.type === 'multi_select' &&
|
||||
currentQuestion.options && (
|
||||
<MultiSelectField
|
||||
options={currentQuestion.options}
|
||||
value={(answers[currentQuestion.id] as string[]) || []}
|
||||
onChange={(v) => setAnswer(currentQuestion.id, v)}
|
||||
otherText={otherInputs[currentQuestion.id] || ''}
|
||||
onOtherChange={(t) =>
|
||||
setOtherInputs((prev) => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: t,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentQuestion?.type === 'text' && (
|
||||
<textarea
|
||||
className="w-full h-20 text-sm border rounded-lg p-2 bg-background resize-none focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder={getI18nText(currentQuestion.placeholder)}
|
||||
maxLength={currentQuestion.max_length || 500}
|
||||
value={(answers[currentQuestion.id] as string) || ''}
|
||||
onChange={(e) => setAnswer(currentQuestion.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentStep(Math.max(0, currentStep - 1))}
|
||||
disabled={currentStep === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!currentQuestion?.required && currentStep < totalSteps - 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentStep(currentStep + 1)}
|
||||
>
|
||||
{getI18nText({ zh_Hans: '跳过', en_US: 'Skip' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentStep < totalSteps - 1 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCurrentStep(currentStep + 1)}
|
||||
disabled={
|
||||
currentQuestion?.required && !answers[currentQuestion?.id]
|
||||
}
|
||||
>
|
||||
{getI18nText({ zh_Hans: '下一题', en_US: 'Next' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={handleSubmit}>
|
||||
{getI18nText({ zh_Hans: '提交', en_US: 'Submit' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Sub-components for flat radio/checkbox style ----
|
||||
|
||||
function SingleSelectField({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
otherText,
|
||||
onOtherChange,
|
||||
}: {
|
||||
options: SurveyOption[];
|
||||
value?: string;
|
||||
onChange: (v: string) => void;
|
||||
otherText: string;
|
||||
onOtherChange: (t: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id}>
|
||||
<button
|
||||
onClick={() => onChange(opt.id)}
|
||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
|
||||
value === opt.id
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{getI18nText(opt.label)}
|
||||
</button>
|
||||
{opt.has_input && value === opt.id && (
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 w-full text-sm border rounded-lg px-3 py-1.5 bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="..."
|
||||
value={otherText}
|
||||
onChange={(e) => onOtherChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiSelectField({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
otherText,
|
||||
onOtherChange,
|
||||
}: {
|
||||
options: SurveyOption[];
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
otherText: string;
|
||||
onOtherChange: (t: string) => void;
|
||||
}) {
|
||||
const toggle = (id: string) => {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((v) => v !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{options.map((opt) => {
|
||||
const selected = value.includes(opt.id);
|
||||
return (
|
||||
<div key={opt.id}>
|
||||
<button
|
||||
onClick={() => toggle(opt.id)}
|
||||
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||
selected
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
<Checkbox checked={selected} className="pointer-events-none" />
|
||||
{getI18nText(opt.label)}
|
||||
</button>
|
||||
{opt.has_input && selected && (
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 w-full text-sm border rounded-lg px-3 py-1.5 bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
placeholder="..."
|
||||
value={otherText}
|
||||
onChange={(e) => onOtherChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import styles from './layout.module.css';
|
||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
|
||||
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
@@ -54,6 +55,8 @@ export default function HomeLayout({
|
||||
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />
|
||||
|
||||
<main className={styles.mainContent}>{mainContent}</main>
|
||||
|
||||
<SurveyWidget />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1007,4 +1007,50 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
return this.get(`/api/v1/monitoring/overview?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// ============ Survey API ============
|
||||
public getSurveyPending(): Promise<{
|
||||
survey: {
|
||||
survey_id: string;
|
||||
version: number;
|
||||
title: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
questions: SurveyQuestion[];
|
||||
} | null;
|
||||
}> {
|
||||
return this.get('/api/v1/survey/pending');
|
||||
}
|
||||
|
||||
public submitSurveyResponse(
|
||||
surveyId: string,
|
||||
answers: Record<string, unknown>,
|
||||
completed: boolean = true,
|
||||
): Promise<object> {
|
||||
return this.post('/api/v1/survey/respond', {
|
||||
survey_id: surveyId,
|
||||
answers,
|
||||
completed,
|
||||
});
|
||||
}
|
||||
|
||||
public dismissSurvey(surveyId: string): Promise<object> {
|
||||
return this.post('/api/v1/survey/dismiss', { survey_id: surveyId });
|
||||
}
|
||||
}
|
||||
|
||||
export interface SurveyQuestion {
|
||||
id: string;
|
||||
type: 'single_select' | 'multi_select' | 'text';
|
||||
title: Record<string, string>;
|
||||
subtitle?: Record<string, string>;
|
||||
required: boolean;
|
||||
options?: SurveyOption[];
|
||||
placeholder?: Record<string, string>;
|
||||
max_length?: number;
|
||||
}
|
||||
|
||||
export interface SurveyOption {
|
||||
id: string;
|
||||
label: Record<string, string>;
|
||||
has_input?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user