feat: AI chat session conclusion + survey completion & management

AI Assistant - Conclude Session:
- 3-step modal: select outcome (resolved/escalated/paused), add notes, AI-generated summary
- AI generates structured ticket notes from conversation transcript (PSA-ready format)
- Copy to clipboard for pasting into ticketing systems
- "Resume in New Chat" for paused sessions (pre-loads context into new chat)
- Backend: POST /chats/{id}/conclude endpoint, conclusion_summary/outcome/concluded_at fields
- Migration 048: add conclusion fields to assistant_chats

Survey Completion Flow:
- Email-to-self option after submission (branded HTML email with formatted responses)
- Finish button navigates to /survey/thank-you page
- Thank you page with close-window message and feedback email callout
- Already-submitted state updated with same messaging
- Backend: POST /survey/email-copy public endpoint

Survey Admin Management:
- Read/unread indicators (cyan dot, bold name, auto-mark on expand)
- Unread count stat card
- Per-row context menu: mark read/unread, archive/unarchive, delete
- Bulk actions bar: select all, mark read/unread, archive, delete
- Show Archived toggle to filter archived responses
- Backend: 7 new admin endpoints (read, unread, archive, unarchive, delete, bulk)
- Migration 049: add is_read, archived_at to survey_responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-03-05 20:00:28 -05:00
parent e4c5948fbd
commit 882f67f42e
20 changed files with 1627 additions and 63 deletions

View File

@@ -38,6 +38,8 @@ export interface SurveyResponseDetail {
responses: Record<string, string | string[]>
source: 'invite' | 'direct'
invite_name: string | null
is_read: boolean
archived_at: string | null
created_at: string
}
@@ -45,6 +47,7 @@ export interface SurveyResponseListResponse {
responses: SurveyResponseDetail[]
total: number
this_week: number
unread: number
}
export const adminApi = {
@@ -175,10 +178,22 @@ export const adminApi = {
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
// Survey Responses
listSurveyResponses: () =>
api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
listSurveyResponses: (includeArchived = false) =>
api.get<SurveyResponseListResponse>('/admin/survey-responses', { params: { include_archived: includeArchived } }).then(r => r.data),
exportSurveyResponsesCsv: () =>
api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),
markResponseRead: (id: string) =>
api.put(`/admin/survey-responses/${id}/read`).then(r => r.data),
markResponseUnread: (id: string) =>
api.put(`/admin/survey-responses/${id}/unread`).then(r => r.data),
archiveResponse: (id: string) =>
api.put(`/admin/survey-responses/${id}/archive`).then(r => r.data),
unarchiveResponse: (id: string) =>
api.put(`/admin/survey-responses/${id}/unarchive`).then(r => r.data),
deleteResponse: (id: string) =>
api.delete(`/admin/survey-responses/${id}`),
bulkActionResponses: (action: string, ids: string[]) =>
api.post('/admin/survey-responses/bulk', { action, ids }).then(r => r.data),
}
export default adminApi

View File

@@ -4,6 +4,8 @@ import type {
ChatListItem,
ChatMessageResponse,
RetentionSettings,
ConcludeChatRequest,
ConcludeChatResponse,
} from '@/types/assistant-chat'
export const assistantChatApi = {
@@ -54,6 +56,14 @@ export const assistantChatApi = {
const response = await apiClient.patch<RetentionSettings>('/assistant/retention', data)
return response.data
},
async concludeChat(chatId: string, data: ConcludeChatRequest): Promise<ConcludeChatResponse> {
const response = await apiClient.post<ConcludeChatResponse>(
`/assistant/chats/${chatId}/conclude`,
data
)
return response.data
},
}
export default assistantChatApi

View File

@@ -0,0 +1,421 @@
import { useState, useEffect } from 'react'
import {
X,
CheckCircle2,
ArrowUpRight,
Pause,
Loader2,
Copy,
Check,
RefreshCw,
ClipboardList,
Sparkles,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
interface ConcludeSessionModalProps {
isOpen: boolean
onClose: () => void
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
onResumeNew: (summary: string) => void
chatTitle: string
}
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
{
value: 'resolved',
label: 'Resolved',
description: 'Issue has been fixed or answered',
icon: CheckCircle2,
color: 'text-emerald-400',
bg: 'bg-emerald-400/10',
border: 'border-emerald-400/30',
},
{
value: 'escalated',
label: 'Escalate',
description: 'Needs to be handed off or escalated',
icon: ArrowUpRight,
color: 'text-amber-400',
bg: 'bg-amber-400/10',
border: 'border-amber-400/30',
},
{
value: 'paused',
label: 'Paused',
description: 'Continuing later — saving progress',
icon: Pause,
color: 'text-blue-400',
bg: 'bg-blue-400/10',
border: 'border-blue-400/30',
},
]
type ModalStep = 'select-outcome' | 'add-notes' | 'summary'
export function ConcludeSessionModal({
isOpen,
onClose,
onConclude,
onResumeNew,
chatTitle,
}: ConcludeSessionModalProps) {
const [step, setStep] = useState<ModalStep>('select-outcome')
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
const [notes, setNotes] = useState('')
const [summary, setSummary] = useState('')
const [generating, setGenerating] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep('select-outcome')
setOutcome(null)
setNotes('')
setSummary('')
setGenerating(false)
setCopied(false)
setError(null)
}
}, [isOpen])
const handleOutcomeSelect = (selected: ConclusionOutcome) => {
setOutcome(selected)
setStep('add-notes')
}
const handleGenerate = async () => {
if (!outcome) return
setGenerating(true)
setError(null)
try {
const result = await onConclude(outcome, notes)
setSummary(result)
setStep('summary')
} catch {
setError('Failed to generate summary. Please try again.')
} finally {
setGenerating(false)
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(summary)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = summary
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleResumeNew = () => {
onResumeNew(summary)
onClose()
}
if (!isOpen) return null
const selectedOutcome = OUTCOMES.find(o => o.value === outcome)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div
className="relative w-full max-w-2xl mx-4 glass-card-static overflow-hidden animate-in fade-in zoom-in-95 duration-200"
style={{
maxHeight: 'calc(100vh - 4rem)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4 border-b shrink-0"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<ClipboardList size={18} className="text-primary" />
</div>
<div>
<h2 className="text-base font-heading font-semibold text-foreground">
Conclude Session
</h2>
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
{chatTitle}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
{/* Step indicator */}
<div
className="px-6 py-3 border-b shrink-0 flex items-center gap-2"
style={{ borderColor: 'var(--glass-border)' }}
>
{(['select-outcome', 'add-notes', 'summary'] as ModalStep[]).map((s, i) => (
<div key={s} className="flex items-center gap-2">
{i > 0 && (
<div
className={cn(
'w-8 h-px',
step === s || (i === 1 && step === 'summary') || (i === 2 && step === 'summary')
? 'bg-primary/40'
: 'bg-[rgba(255,255,255,0.06)]'
)}
/>
)}
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-label font-medium transition-colors',
step === s
? 'bg-gradient-brand text-[#101114]'
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
? 'bg-primary/20 text-primary'
: 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
)}
>
{i + 1}
</div>
<span
className={cn(
'text-xs font-label',
step === s ? 'text-foreground' : 'text-muted-foreground'
)}
>
{s === 'select-outcome' ? 'Outcome' : s === 'add-notes' ? 'Notes' : 'Summary'}
</span>
</div>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Step 1: Select Outcome */}
{step === 'select-outcome' && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground mb-4">
How did this session end?
</p>
{OUTCOMES.map(o => {
const Icon = o.icon
return (
<button
key={o.value}
onClick={() => handleOutcomeSelect(o.value)}
className={cn(
'w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left',
'hover:scale-[1.01] active:scale-[0.99]',
'bg-[rgba(255,255,255,0.02)] border-[rgba(255,255,255,0.06)]',
'hover:border-[rgba(255,255,255,0.12)] hover:bg-[rgba(255,255,255,0.04)]'
)}
>
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center', o.bg)}>
<Icon size={20} className={o.color} />
</div>
<div>
<span className="text-sm font-semibold text-foreground block">{o.label}</span>
<span className="text-xs text-muted-foreground">{o.description}</span>
</div>
</button>
)
})}
</div>
)}
{/* Step 2: Add Notes */}
{step === 'add-notes' && selectedOutcome && (
<div className="space-y-4">
{/* Selected outcome badge */}
<div className="flex items-center gap-2">
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
<button
onClick={() => setStep('select-outcome')}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Change
</button>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-2">
Additional Notes (optional)
</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder={
outcome === 'resolved'
? 'Any additional context about the resolution...'
: outcome === 'escalated'
? 'Reason for escalation, who to assign to...'
: 'What still needs to be done, where you left off...'
}
rows={4}
className="w-full resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
style={{ borderColor: 'var(--glass-border)' }}
/>
</div>
{error && (
<div className="text-sm text-rose-400 bg-rose-400/10 border border-rose-400/20 rounded-lg px-4 py-2">
{error}
</div>
)}
</div>
)}
{/* Step 3: Summary */}
{step === 'summary' && (
<div className="space-y-4">
{/* Outcome badge */}
{selectedOutcome && (
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
</div>
)}
{/* Generated summary */}
<div
className="rounded-xl border p-5 bg-[rgba(255,255,255,0.02)]"
style={{ borderColor: 'var(--glass-border)' }}
>
<div className="flex items-center justify-between mb-3">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground flex items-center gap-1.5">
<Sparkles size={10} className="text-primary" />
Generated Ticket Notes
</span>
</div>
<div className="prose-sm text-foreground">
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
</div>
</div>
</div>
)}
</div>
{/* Footer actions */}
<div
className="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-3"
style={{ borderColor: 'var(--glass-border)' }}
>
{step === 'select-outcome' && (
<>
<div />
<button
onClick={onClose}
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Cancel
</button>
</>
)}
{step === 'add-notes' && (
<>
<button
onClick={() => setStep('select-outcome')}
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Back
</button>
<button
onClick={handleGenerate}
disabled={generating}
className="flex items-center gap-2 bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-50"
>
{generating ? (
<>
<Loader2 size={15} className="animate-spin" />
Generating...
</>
) : (
<>
<Sparkles size={15} />
Generate Summary
</>
)}
</button>
</>
)}
{step === 'summary' && (
<>
<div className="flex items-center gap-2">
{outcome === 'paused' && (
<button
onClick={handleResumeNew}
className="flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
>
<RefreshCw size={14} />
Resume in New Chat
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
copied
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
: 'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]'
)}
>
{copied ? (
<>
<Check size={15} />
Copied!
</>
) : (
<>
<Copy size={15} />
Copy to Clipboard
</>
)}
</button>
<button
onClick={onClose}
className="px-4 py-2.5 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Done
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles, Send, Loader2 } from 'lucide-react'
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
import { assistantChatApi } from '@/api/assistantChat'
import { toast } from '@/lib/toast'
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage'
import type { ChatListItem, AssistantChatMessage as ChatMessageType } from '@/types/assistant-chat'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import type { ChatListItem, AssistantChatMessage as ChatMessageType, ConclusionOutcome } from '@/types/assistant-chat'
import type { SuggestedFlow } from '@/types/copilot'
interface MessageWithMeta extends ChatMessageType {
@@ -17,6 +18,7 @@ export default function AssistantChatPage() {
const [messages, setMessages] = useState<MessageWithMeta[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [showConclude, setShowConclude] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
@@ -120,6 +122,55 @@ export default function AssistantChatPage() {
}
}
const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise<string> => {
if (!activeChatId) throw new Error('No active chat')
const response = await assistantChatApi.concludeChat(activeChatId, { outcome, notes: notes || undefined })
// Update chat in sidebar to show concluded status
setChats(prev =>
prev.map(c =>
c.id === activeChatId
? { ...c, concluded_at: response.concluded_at, conclusion_outcome: outcome }
: c
)
)
return response.summary
}
const handleResumeNew = async (summary: string) => {
try {
const chat = await assistantChatApi.createChat()
setChats(prev => [
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
...prev,
])
setActiveChatId(chat.id)
setMessages([])
// Send the summary as the first message to prime the new chat
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
setInput('')
setMessages([{ role: 'user', content: resumePrompt }])
setLoading(true)
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
setMessages(prev => [
...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
])
setChats(prev =>
prev.map(c =>
c.id === chat.id
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
: c
)
)
} catch {
toast.error('Failed to create resume chat')
} finally {
setLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
@@ -194,13 +245,27 @@ export default function AssistantChatPage() {
style={{ borderColor: 'var(--glass-border)' }}
disabled={loading}
/>
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
>
<Send size={18} />
</button>
<div className="flex flex-col gap-2">
<button
onClick={handleSend}
disabled={!input.trim() || loading}
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
title="Send message"
>
<Send size={18} />
</button>
{messages.length >= 2 && (
<button
onClick={() => setShowConclude(true)}
disabled={loading}
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
style={{ borderColor: 'var(--glass-border)' }}
title="Conclude session"
>
<Flag size={18} />
</button>
)}
</div>
</div>
</div>
</>
@@ -225,6 +290,15 @@ export default function AssistantChatPage() {
</div>
)}
</div>
{/* Conclude Session Modal */}
<ConcludeSessionModal
isOpen={showConclude}
onClose={() => setShowConclude(false)}
onConclude={handleConclude}
onResumeNew={handleResumeNew}
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/common/BrandLogo'
// ── Survey Data Types ──
@@ -147,6 +147,12 @@ export default function SurveyPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [submitError, setSubmitError] = useState('')
const [emailInput, setEmailInput] = useState('')
const [emailSending, setEmailSending] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [emailError, setEmailError] = useState('')
const [responseId, setResponseId] = useState<string | null>(null)
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get('t')
@@ -203,6 +209,8 @@ export default function SurveyPage() {
const errData = await res.json().catch(() => null)
throw new Error(errData?.detail || `Submission failed (${res.status})`)
}
const data = await res.json()
setResponseId(data.id)
setIsComplete(true)
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (err) {
@@ -257,9 +265,20 @@ export default function SurveyPage() {
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed">
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed mb-3">
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
</p>
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-8">
You can safely close this browser window now.
</p>
<div className="glass-card-static p-5 max-w-[400px] mx-auto text-center">
<p className="text-xs text-muted-foreground leading-relaxed">
Have feedback unrelated to the survey?{' '}
<a href="mailto:feedback@resolutionflow.com" className="text-primary hover:underline font-medium">
feedback@resolutionflow.com
</a>
</p>
</div>
</div>
</div>
</div>
@@ -385,13 +404,86 @@ export default function SurveyPage() {
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Done — Thank You!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-7 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. We truly appreciate your time and expertise.
<h2 className="font-heading text-2xl font-bold mb-2.5">Response Submitted!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-8 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. Would you like a copy of your responses?
</p>
{/* Email a copy */}
<div className="glass-card-static p-6 max-w-[420px] mx-auto mb-5">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
Email a copy to yourself
</p>
{!emailSent ? (
<div className="flex gap-2">
<input
type="email"
value={emailInput}
onChange={e => setEmailInput(e.target.value)}
placeholder="your@email.com"
className="flex-1 rounded-[9px] px-3.5 py-2.5 text-sm text-foreground placeholder:text-[#5a6170] focus:outline-none"
style={{ background: 'rgba(16, 17, 20, 0.6)', border: '1px solid var(--glass-border)' }}
onFocus={e => { e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.4)' }}
onBlur={e => { e.currentTarget.style.borderColor = 'var(--glass-border)' }}
disabled={emailSending}
/>
<button
onClick={async () => {
if (!emailInput.trim() || !responseId) return
setEmailSending(true)
setEmailError('')
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const res = await fetch(`${apiUrl}/api/v1/survey/email-copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailInput.trim(), response_id: responseId }),
})
if (!res.ok) {
const err = await res.json().catch(() => null)
throw new Error(err?.detail || 'Failed to send')
}
setEmailSent(true)
} catch (err) {
setEmailError(err instanceof Error ? err.message : 'Failed to send email')
} finally {
setEmailSending(false)
}
}}
disabled={!emailInput.trim() || emailSending}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[9px] text-sm font-semibold bg-gradient-brand text-[#101114] transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
{emailSending ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Sending...
</>
) : 'Send'}
</button>
</div>
) : (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-emerald-400">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg>
Email sent! Check your inbox.
</div>
)}
{emailError && (
<p className="text-xs text-rose-400 mt-2">{emailError}</p>
)}
</div>
{/* Copy + Finish buttons */}
<div className="flex gap-2.5 justify-center flex-wrap">
<button onClick={copyAll} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
Copy Responses to Clipboard
<button onClick={copyAll} className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[10px] text-sm font-semibold transition-all duration-150 text-muted-foreground hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy to Clipboard
</button>
<button
onClick={() => navigate('/survey/thank-you')}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]"
>
Finish
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>

View File

@@ -0,0 +1,76 @@
import { BrandLogo } from '@/components/common/BrandLogo'
export default function SurveyThankYouPage() {
return (
<div className="min-h-screen bg-background text-foreground">
{/* Atmosphere orbs */}
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
<div
className="absolute"
style={{ top: '-200px', left: '-100px', width: '600px', height: '600px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, rgba(6, 182, 212, 0.03) 40%, transparent 70%)', filter: 'blur(80px)' }}
/>
<div
className="absolute"
style={{ bottom: '-150px', right: '-100px', width: '500px', height: '500px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(52, 211, 153, 0.08) 0%, rgba(52, 211, 153, 0.02) 40%, transparent 70%)', filter: 'blur(80px)' }}
/>
</div>
{/* Top bar */}
<div className="sticky top-0 z-50" style={{ backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: 'rgba(16, 17, 20, 0.85)', borderBottom: '1px solid var(--glass-border)' }}>
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-5 py-3.5">
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline">
<BrandLogo size="sm" />
<span>Resolution<span className="text-gradient-brand">Flow</span></span>
</a>
</div>
</div>
<div className="relative z-10 mx-auto max-w-[680px] px-5">
<div className="text-center pt-[120px] animate-fade-in-up">
{/* Success icon */}
<div className="w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)', border: '1px solid rgba(52, 211, 153, 0.15)' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2">
<path d="M20 6L9 17l-5-5"/>
</svg>
</div>
<h1 className="font-heading text-[clamp(24px,4vw,32px)] font-extrabold leading-tight mb-3">
Thank You!
</h1>
<p className="text-[15px] text-muted-foreground max-w-[460px] mx-auto leading-relaxed mb-3">
Your response has been recorded. Your expertise will directly shape how FlowPilot thinks about troubleshooting.
</p>
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-10">
You can safely close this browser window now.
</p>
{/* Divider */}
<div className="mx-auto w-12 h-px mb-10" style={{ background: 'var(--glass-border)' }} />
{/* Feedback callout */}
<div
className="glass-card-static p-6 text-center max-w-[480px] mx-auto"
>
<div className="flex items-center justify-center gap-2 mb-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary font-semibold">
Have Feedback?
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
If you have any feedback unrelated to the survey, we'd love to hear from you at{' '}
<a
href="mailto:feedback@resolutionflow.com"
className="text-primary hover:underline font-medium"
>
feedback@resolutionflow.com
</a>
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,8 +1,24 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type SurveyResponseDetail, type SurveyResponseListResponse } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import {
ChevronDown,
Download,
User,
Link2,
Loader2,
Eye,
EyeOff,
Archive,
ArchiveRestore,
Trash2,
CheckSquare,
Square,
MoreHorizontal,
Circle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
@@ -70,7 +86,7 @@ function AnswerDisplay({ value, type }: { value: string | string[] | undefined;
function ExpandedDetail({ response }: { response: SurveyResponseDetail }) {
return (
<tr>
<td colSpan={6} className="p-0">
<td colSpan={8} className="p-0">
<div
className="px-6 py-5"
style={{
@@ -106,25 +122,57 @@ function ResponseRow({
response,
index,
isExpanded,
isSelected,
onToggle,
onSelect,
onMarkRead,
onArchive,
onDelete,
}: {
response: SurveyResponseDetail
index: number
isExpanded: boolean
isSelected: boolean
onToggle: () => void
onSelect: () => void
onMarkRead: () => void
onArchive: () => void
onDelete: () => void
}) {
const answeredCount = QUESTIONS.filter((q) => {
const val = response.responses[q.id]
return val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)
}).length
const [showMenu, setShowMenu] = useState(false)
return (
<>
<tr
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer"
onClick={onToggle}
className={cn(
'border-b border-border/50 transition-colors cursor-pointer',
!response.is_read && 'bg-primary/[0.03]',
'hover:bg-[rgba(255,255,255,0.02)]'
)}
>
<td className="px-4 py-3 w-8">
{/* Checkbox */}
<td className="px-2 py-3 w-8" onClick={e => { e.stopPropagation(); onSelect() }}>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-primary cursor-pointer" />
) : (
<Square className="h-4 w-4 text-muted-foreground/40 cursor-pointer hover:text-muted-foreground" />
)}
</td>
{/* Unread dot */}
<td className="px-1 py-3 w-6" onClick={onToggle}>
{!response.is_read && (
<Circle className="h-2.5 w-2.5 fill-primary text-primary" />
)}
</td>
{/* Expand chevron */}
<td className="px-2 py-3 w-8" onClick={onToggle}>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
@@ -132,11 +180,11 @@ function ResponseRow({
)}
/>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index + 1}</td>
<td className="px-4 py-3 text-sm text-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>{index + 1}</td>
<td className={cn('px-4 py-3 text-sm', !response.is_read ? 'text-foreground font-medium' : 'text-foreground')} onClick={onToggle}>
{response.respondent_name || <span className="text-muted-foreground italic">Anonymous</span>}
</td>
<td className="px-4 py-3">
<td className="px-4 py-3" onClick={onToggle}>
{response.source === 'invite' ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider bg-primary/10 text-primary">
<User className="h-3 w-3" />
@@ -152,16 +200,57 @@ function ResponseRow({
</span>
)}
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>
{new Date(response.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
<td className="px-4 py-3 text-sm text-muted-foreground" onClick={onToggle}>
{answeredCount} / {QUESTIONS.length}
</td>
{/* Actions */}
<td className="px-3 py-3 w-10 relative">
<button
onClick={e => { e.stopPropagation(); setShowMenu(!showMenu) }}
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
<div
className="absolute right-3 top-full z-50 mt-1 w-44 rounded-xl py-1 shadow-xl"
style={{ background: 'rgba(24, 26, 31, 0.95)', border: '1px solid var(--glass-border)', backdropFilter: 'blur(16px)' }}
>
<button
onClick={() => { onMarkRead(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.is_read ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{response.is_read ? 'Mark Unread' : 'Mark Read'}
</button>
<button
onClick={() => { onArchive(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
>
{response.archived_at ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
{response.archived_at ? 'Unarchive' : 'Archive'}
</button>
<div className="my-1 border-t" style={{ borderColor: 'var(--glass-border)' }} />
<button
onClick={() => { onDelete(); setShowMenu(false) }}
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
</>
)}
</td>
</tr>
{isExpanded && <ExpandedDetail response={response} />}
</>
@@ -174,20 +263,24 @@ export default function SurveyResponsesPage() {
const [error, setError] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showArchived, setShowArchived] = useState(false)
const fetchData = useCallback(async () => {
try {
const result = await adminApi.listSurveyResponses(showArchived)
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}, [showArchived])
useEffect(() => {
const fetchData = async () => {
try {
const result = await adminApi.listSurveyResponses()
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}
setLoading(true)
fetchData()
}, [])
}, [fetchData])
const handleExport = async () => {
setExporting(true)
@@ -206,6 +299,112 @@ export default function SurveyResponsesPage() {
}
}
const handleMarkRead = async (id: string, currentlyRead: boolean) => {
try {
if (currentlyRead) {
await adminApi.markResponseUnread(id)
} else {
await adminApi.markResponseRead(id)
}
setData(prev => prev ? {
...prev,
unread: prev.unread + (currentlyRead ? 1 : -1),
responses: prev.responses.map(r => r.id === id ? { ...r, is_read: !currentlyRead } : r),
} : prev)
} catch {
toast.error('Failed to update read status')
}
}
const handleArchive = async (id: string, currentlyArchived: boolean) => {
try {
if (currentlyArchived) {
await adminApi.unarchiveResponse(id)
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: null } : r),
} : prev)
} else {
await adminApi.archiveResponse(id)
if (!showArchived) {
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
} else {
setData(prev => prev ? {
...prev,
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: new Date().toISOString() } : r),
} : prev)
}
}
toast.success(currentlyArchived ? 'Response unarchived' : 'Response archived')
} catch {
toast.error('Failed to update archive status')
}
}
const handleDelete = async (id: string) => {
if (!confirm('Permanently delete this response? This cannot be undone.')) return
try {
await adminApi.deleteResponse(id)
setData(prev => prev ? {
...prev,
total: prev.total - 1,
responses: prev.responses.filter(r => r.id !== id),
} : prev)
setSelectedIds(prev => { const next = new Set(prev); next.delete(id); return next })
toast.success('Response deleted')
} catch {
toast.error('Failed to delete response')
}
}
const handleBulkAction = async (action: string) => {
if (selectedIds.size === 0) return
if (action === 'delete' && !confirm(`Permanently delete ${selectedIds.size} response(s)?`)) return
try {
await adminApi.bulkActionResponses(action, Array.from(selectedIds))
setSelectedIds(new Set())
fetchData()
toast.success(`${action.replace('_', ' ')} applied to ${selectedIds.size} response(s)`)
} catch {
toast.error('Bulk action failed')
}
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
const responses = data?.responses ?? []
if (selectedIds.size === responses.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(responses.map(r => r.id)))
}
}
// Auto-mark as read when expanding
const handleExpand = (id: string) => {
const newId = expandedId === id ? null : id
setExpandedId(newId)
if (newId) {
const resp = data?.responses.find(r => r.id === newId)
if (resp && !resp.is_read) {
handleMarkRead(newId, false)
}
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -230,18 +429,33 @@ export default function SurveyResponsesPage() {
title="Survey Responses"
description={`${data?.total ?? 0} total responses collected`}
action={
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
<div className="flex items-center gap-2">
{/* Archive toggle */}
<button
onClick={() => setShowArchived(!showArchived)}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs font-medium transition-colors border',
showArchived
? 'bg-primary/10 text-primary border-primary/20'
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)]'
)}
>
<Archive className="h-3.5 w-3.5" />
{showArchived ? 'Showing Archived' : 'Show Archived'}
</button>
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
</div>
}
/>
@@ -269,14 +483,82 @@ export default function SurveyResponsesPage() {
{data?.this_week ?? 0}
</p>
</div>
<div className="glass-card-static px-5 py-4 flex-1">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">
Unread
</p>
<p className={cn(
'text-2xl font-heading font-bold',
(data?.unread ?? 0) > 0 ? 'text-primary' : 'text-foreground'
)}>
{data?.unread ?? 0}
</p>
</div>
</div>
{/* Bulk actions bar */}
{selectedIds.size > 0 && (
<div
className="flex items-center gap-3 rounded-xl px-4 py-2.5"
style={{ background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.15)' }}
>
<span className="text-sm text-primary font-medium">
{selectedIds.size} selected
</span>
<div className="flex-1" />
<button
onClick={() => handleBulkAction('mark_read')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Eye className="h-3.5 w-3.5" />
Mark Read
</button>
<button
onClick={() => handleBulkAction('mark_unread')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<EyeOff className="h-3.5 w-3.5" />
Mark Unread
</button>
<button
onClick={() => handleBulkAction('archive')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<Archive className="h-3.5 w-3.5" />
Archive
</button>
<button
onClick={() => handleBulkAction('delete')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
Clear
</button>
</div>
)}
{/* Table */}
<div className="glass-card-static overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3 w-8" />
<th className="px-2 py-3 w-8">
<button onClick={toggleSelectAll} className="text-muted-foreground/40 hover:text-muted-foreground">
{selectedIds.size > 0 && selectedIds.size === responses.length ? (
<CheckSquare className="h-4 w-4 text-primary" />
) : (
<Square className="h-4 w-4" />
)}
</button>
</th>
<th className="px-1 py-3 w-6" />
<th className="px-2 py-3 w-8" />
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
#
</th>
@@ -292,13 +574,14 @@ export default function SurveyResponsesPage() {
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Answered
</th>
<th className="px-3 py-3 w-10" />
</tr>
</thead>
<tbody>
{responses.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-sm text-muted-foreground">
No survey responses yet.
<td colSpan={9} className="px-4 py-12 text-center text-sm text-muted-foreground">
{showArchived ? 'No archived responses.' : 'No survey responses yet.'}
</td>
</tr>
) : (
@@ -308,9 +591,12 @@ export default function SurveyResponsesPage() {
response={response}
index={index}
isExpanded={expandedId === response.id}
onToggle={() =>
setExpandedId(expandedId === response.id ? null : response.id)
}
isSelected={selectedIds.has(response.id)}
onToggle={() => handleExpand(response.id)}
onSelect={() => toggleSelect(response.id)}
onMarkRead={() => handleMarkRead(response.id, response.is_read)}
onArchive={() => handleArchive(response.id, !!response.archived_at)}
onDelete={() => handleDelete(response.id)}
/>
))
)}

View File

@@ -11,6 +11,7 @@ import {
// Public pages
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
const SurveyPage = lazy(() => import('@/pages/SurveyPage'))
const SurveyThankYouPage = lazy(() => import('@/pages/SurveyThankYouPage'))
// Standalone auth pages
const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage'))
@@ -108,6 +109,15 @@ export const router = createBrowserRouter([
),
errorElement: <RouteError />,
},
{
path: '/survey/thank-you',
element: (
<Suspense fallback={<PageLoader />}>
<SurveyThankYouPage />
</Suspense>
),
errorElement: <RouteError />,
},
{
path: '/share/:shareToken',
element: (

View File

@@ -34,4 +34,17 @@ export interface RetentionSettings {
chat_retention_max_count: number | null
}
export type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
export interface ConcludeChatRequest {
outcome: ConclusionOutcome
notes?: string
}
export interface ConcludeChatResponse {
summary: string
outcome: ConclusionOutcome
concluded_at: string
}
export type { SuggestedFlow }