feat: add CSAT rating modal after session completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-16 00:40:43 -05:00
parent ddd5d4721a
commit 5865b1fba2
3 changed files with 155 additions and 0 deletions

View 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>
)
}

View File

@@ -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">

View File

@@ -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}