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 { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||||
|
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||||
|
|
||||||
interface StepState {
|
interface StepState {
|
||||||
notes: string
|
notes: string
|
||||||
@@ -36,6 +37,7 @@ export function ProceduralNavigationPage() {
|
|||||||
const [completedAt, setCompletedAt] = useState<string>('')
|
const [completedAt, setCompletedAt] = useState<string>('')
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
const [paramsOpen, setParamsOpen] = useState(false)
|
const [paramsOpen, setParamsOpen] = useState(false)
|
||||||
|
const [showCsatModal, setShowCsatModal] = useState(false)
|
||||||
const [elapsedMinutes, setElapsedMinutes] = useState(0)
|
const [elapsedMinutes, setElapsedMinutes] = useState(0)
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
@@ -245,6 +247,9 @@ export function ProceduralNavigationPage() {
|
|||||||
})
|
})
|
||||||
setCompletedAt(completedTime)
|
setCompletedAt(completedTime)
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
|
if (!hasBeenRated(session.id)) {
|
||||||
|
setShowCsatModal(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentStepIndex(currentStepIndex + 1)
|
setCurrentStepIndex(currentStepIndex + 1)
|
||||||
}
|
}
|
||||||
@@ -275,6 +280,10 @@ export function ProceduralNavigationPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCsatClose = () => {
|
||||||
|
setShowCsatModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -415,6 +424,15 @@ export function ProceduralNavigationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CSAT Modal */}
|
||||||
|
{session && (
|
||||||
|
<CSATModal
|
||||||
|
isOpen={showCsatModal}
|
||||||
|
onClose={handleCsatClose}
|
||||||
|
sessionId={session.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Parameters popover */}
|
{/* Parameters popover */}
|
||||||
{paramsOpen && (
|
{paramsOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<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 { toast } from '@/lib/toast'
|
||||||
import { Modal } from '@/components/common/Modal'
|
import { Modal } from '@/components/common/Modal'
|
||||||
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||||
|
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function TreeNavigationPage() {
|
|||||||
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
||||||
const [showSharePopover, setShowSharePopover] = useState(false)
|
const [showSharePopover, setShowSharePopover] = useState(false)
|
||||||
const [showShareModal, setShowShareModal] = useState(false)
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
|
const [showCsatModal, setShowCsatModal] = useState(false)
|
||||||
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
||||||
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
||||||
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -196,6 +198,13 @@ export function TreeNavigationPage() {
|
|||||||
setCompletionSource('standard')
|
setCompletionSource('standard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCsatClose = () => {
|
||||||
|
setShowCsatModal(false)
|
||||||
|
if (session) {
|
||||||
|
navigate(`/sessions/${session.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
||||||
const customStepFlow = useCustomStepFlow({
|
const customStepFlow = useCustomStepFlow({
|
||||||
tree,
|
tree,
|
||||||
@@ -451,6 +460,8 @@ export function TreeNavigationPage() {
|
|||||||
setPendingCompletionDecision(null)
|
setPendingCompletionDecision(null)
|
||||||
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
||||||
customStepFlow.setShowForkModal(true)
|
customStepFlow.setShowForkModal(true)
|
||||||
|
} else if (!hasBeenRated(session.id)) {
|
||||||
|
setShowCsatModal(true)
|
||||||
} else {
|
} else {
|
||||||
navigate(`/sessions/${session.id}`)
|
navigate(`/sessions/${session.id}`)
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1199,14 @@ export function TreeNavigationPage() {
|
|||||||
isSubmitting={isCompleting}
|
isSubmitting={isCompleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{session && (
|
||||||
|
<CSATModal
|
||||||
|
isOpen={showCsatModal}
|
||||||
|
onClose={handleCsatClose}
|
||||||
|
sessionId={session.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Modal */}
|
{/* Keyboard Shortcuts Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={shortcutsModalOpen}
|
isOpen={shortcutsModalOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user