feat: add CSAT rating modal after session completion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
118
frontend/src/components/session/CSATModal.tsx
Normal file
118
frontend/src/components/session/CSATModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react'
|
||||
import { Star } from 'lucide-react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { analyticsApi } from '@/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CSATModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const RATED_SESSIONS_KEY = 'rf-rated-sessions'
|
||||
|
||||
function getRatedSessions(): string[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RATED_SESSIONS_KEY) || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function markSessionRated(sessionId: string) {
|
||||
const rated = getRatedSessions()
|
||||
rated.push(sessionId)
|
||||
localStorage.setItem(RATED_SESSIONS_KEY, JSON.stringify(rated.slice(-100)))
|
||||
}
|
||||
|
||||
export function hasBeenRated(sessionId: string): boolean {
|
||||
return getRatedSessions().includes(sessionId)
|
||||
}
|
||||
|
||||
export function CSATModal({ isOpen, onClose, sessionId }: CSATModalProps) {
|
||||
const [rating, setRating] = useState(0)
|
||||
const [hoveredRating, setHoveredRating] = useState(0)
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (rating === 0 || submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await analyticsApi.rateSession(sessionId, rating, comment || undefined)
|
||||
markSessionRated(sessionId)
|
||||
onClose()
|
||||
} catch {
|
||||
// Silently fail — still close
|
||||
onClose()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
markSessionRated(sessionId)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleSkip} title="How was this flow?" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your feedback helps flow authors improve troubleshooting paths.
|
||||
</p>
|
||||
|
||||
{/* Star rating */}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setRating(value)}
|
||||
onMouseEnter={() => setHoveredRating(value)}
|
||||
onMouseLeave={() => setHoveredRating(0)}
|
||||
className="p-1 transition-colors"
|
||||
>
|
||||
<Star
|
||||
size={28}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
(hoveredRating || rating) >= value
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'fill-none text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Any comments? (optional)"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20 resize-none"
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={rating === 0 || submitting}
|
||||
className="rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { CompletionSummary } from '@/components/procedural/CompletionSummary'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||
|
||||
interface StepState {
|
||||
notes: string
|
||||
@@ -36,6 +37,7 @@ export function ProceduralNavigationPage() {
|
||||
const [completedAt, setCompletedAt] = useState<string>('')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [paramsOpen, setParamsOpen] = useState(false)
|
||||
const [showCsatModal, setShowCsatModal] = useState(false)
|
||||
const [elapsedMinutes, setElapsedMinutes] = useState(0)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
@@ -245,6 +247,9 @@ export function ProceduralNavigationPage() {
|
||||
})
|
||||
setCompletedAt(completedTime)
|
||||
setIsComplete(true)
|
||||
if (!hasBeenRated(session.id)) {
|
||||
setShowCsatModal(true)
|
||||
}
|
||||
} else {
|
||||
setCurrentStepIndex(currentStepIndex + 1)
|
||||
}
|
||||
@@ -275,6 +280,10 @@ export function ProceduralNavigationPage() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCsatClose = () => {
|
||||
setShowCsatModal(false)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -415,6 +424,15 @@ export function ProceduralNavigationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSAT Modal */}
|
||||
{session && (
|
||||
<CSATModal
|
||||
isOpen={showCsatModal}
|
||||
onClose={handleCsatClose}
|
||||
sessionId={session.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Parameters popover */}
|
||||
{paramsOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy,
|
||||
import { toast } from '@/lib/toast'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||
|
||||
@@ -53,6 +54,7 @@ export function TreeNavigationPage() {
|
||||
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
||||
const [showSharePopover, setShowSharePopover] = useState(false)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showCsatModal, setShowCsatModal] = useState(false)
|
||||
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
||||
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
||||
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
||||
@@ -196,6 +198,13 @@ export function TreeNavigationPage() {
|
||||
setCompletionSource('standard')
|
||||
}
|
||||
|
||||
const handleCsatClose = () => {
|
||||
setShowCsatModal(false)
|
||||
if (session) {
|
||||
navigate(`/sessions/${session.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
||||
const customStepFlow = useCustomStepFlow({
|
||||
tree,
|
||||
@@ -451,6 +460,8 @@ export function TreeNavigationPage() {
|
||||
setPendingCompletionDecision(null)
|
||||
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
||||
customStepFlow.setShowForkModal(true)
|
||||
} else if (!hasBeenRated(session.id)) {
|
||||
setShowCsatModal(true)
|
||||
} else {
|
||||
navigate(`/sessions/${session.id}`)
|
||||
}
|
||||
@@ -1188,6 +1199,14 @@ export function TreeNavigationPage() {
|
||||
isSubmitting={isCompleting}
|
||||
/>
|
||||
|
||||
{session && (
|
||||
<CSATModal
|
||||
isOpen={showCsatModal}
|
||||
onClose={handleCsatClose}
|
||||
sessionId={session.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
<Modal
|
||||
isOpen={shortcutsModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user