3,200+ hardcoded color values replaced with CSS variable-backed Tailwind classes (bg-card, text-foreground, border-border, etc.). Enables light mode via CSS variable swap. Only syntax highlighting colors and intentional one-offs remain hardcoded (~15 values). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import { MessageSquareText, Send, CheckCircle2, ChevronDown } from 'lucide-react'
|
|
import { PageMeta } from '@/components/common/PageMeta'
|
|
import { useAuthStore } from '@/store/authStore'
|
|
import { feedbackApi } from '@/api'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
|
|
// TODO: Post-session contextual feedback prompt — after completing a troubleshooting
|
|
// session, show a subtle inline prompt like "How was this flow? Quick feedback →"
|
|
// that opens a lightweight version of this form pre-tagged with tree/session context.
|
|
|
|
const FEEDBACK_TYPES = [
|
|
{ value: 'Bug Report', description: 'Something is broken or not working as expected' },
|
|
{ value: 'Feature Request', description: "An idea for something new you'd like to see" },
|
|
{ value: 'Usability Issue', description: 'Something works but is confusing or hard to use' },
|
|
{ value: 'Documentation', description: 'Feedback on help docs, tooltips, or in-app guidance' },
|
|
{ value: 'General Feedback', description: 'Anything else — thoughts, impressions, suggestions' },
|
|
] as const
|
|
|
|
export function FeedbackPage() {
|
|
const user = useAuthStore(s => s.user)
|
|
|
|
const [email, setEmail] = useState(user?.email ?? '')
|
|
const [feedbackType, setFeedbackType] = useState<string>('')
|
|
const [message, setMessage] = useState('')
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [submitted, setSubmitted] = useState(false)
|
|
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false)
|
|
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
const listboxRef = useRef<HTMLDivElement>(null)
|
|
|
|
const canSubmit = email.trim() && feedbackType && message.trim().length >= 10
|
|
|
|
const selectedType = FEEDBACK_TYPES.find(t => t.value === feedbackType)
|
|
|
|
const handleSelectType = (value: string) => {
|
|
setFeedbackType(value)
|
|
setTypeDropdownOpen(false)
|
|
setHighlightedIndex(-1)
|
|
triggerRef.current?.focus()
|
|
}
|
|
|
|
const handleDropdownKeyDown = (e: React.KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
if (!typeDropdownOpen) {
|
|
setTypeDropdownOpen(true)
|
|
setHighlightedIndex(0)
|
|
} else {
|
|
setHighlightedIndex(i => (i + 1) % FEEDBACK_TYPES.length)
|
|
}
|
|
break
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
if (!typeDropdownOpen) {
|
|
setTypeDropdownOpen(true)
|
|
setHighlightedIndex(FEEDBACK_TYPES.length - 1)
|
|
} else {
|
|
setHighlightedIndex(i => (i - 1 + FEEDBACK_TYPES.length) % FEEDBACK_TYPES.length)
|
|
}
|
|
break
|
|
case 'Enter':
|
|
case ' ':
|
|
e.preventDefault()
|
|
if (typeDropdownOpen && highlightedIndex >= 0) {
|
|
handleSelectType(FEEDBACK_TYPES[highlightedIndex].value)
|
|
} else {
|
|
setTypeDropdownOpen(true)
|
|
setHighlightedIndex(0)
|
|
}
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
setTypeDropdownOpen(false)
|
|
setHighlightedIndex(-1)
|
|
triggerRef.current?.focus()
|
|
break
|
|
case 'Tab':
|
|
if (typeDropdownOpen && highlightedIndex >= 0) {
|
|
handleSelectType(FEEDBACK_TYPES[highlightedIndex].value)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!typeDropdownOpen) return
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
triggerRef.current && !triggerRef.current.contains(e.target as Node) &&
|
|
listboxRef.current && !listboxRef.current.contains(e.target as Node)
|
|
) {
|
|
setTypeDropdownOpen(false)
|
|
setHighlightedIndex(-1)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [typeDropdownOpen])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!canSubmit || isSubmitting) return
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
const response = await feedbackApi.submit({
|
|
email: email.trim(),
|
|
feedback_type: feedbackType,
|
|
message: message.trim(),
|
|
})
|
|
if (response.success) {
|
|
setSubmitted(true)
|
|
setFeedbackType('')
|
|
setMessage('')
|
|
}
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
|
toast.error(error.response?.data?.detail || 'Failed to submit feedback. Please try again.')
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleNewFeedback = () => {
|
|
setSubmitted(false)
|
|
setEmail(user?.email ?? '')
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-y-auto h-full">
|
|
<PageMeta title="Feedback" />
|
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
|
{/* Page header */}
|
|
<div className="mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<MessageSquareText className="h-8 w-8 text-muted-foreground" />
|
|
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Send Feedback</h1>
|
|
</div>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Help us improve ResolutionFlow. Report bugs, request features, or share your thoughts.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-2xl">
|
|
{submitted ? (
|
|
<div className="bg-card border border-border rounded-xl p-8 text-center">
|
|
<CheckCircle2 className="mx-auto h-12 w-12 text-green-500 mb-4" />
|
|
<h2 className="text-xl font-semibold text-foreground mb-2">Thank you for your feedback!</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
We've received your submission and will review it shortly. Check your email for a confirmation.
|
|
</p>
|
|
<button
|
|
onClick={handleNewFeedback}
|
|
className="bg-primary text-white font-medium px-6 py-2.5 rounded-lg hover:brightness-110 transition-opacity"
|
|
>
|
|
Send More Feedback
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<form onSubmit={handleSubmit} className="bg-card border border-border rounded-xl p-4 sm:p-6 space-y-5">
|
|
{/* Email */}
|
|
<div>
|
|
<label htmlFor="feedback-email" className="block text-sm font-medium text-foreground mb-1.5">
|
|
Email Address
|
|
</label>
|
|
<input
|
|
id="feedback-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={e => setEmail(e.target.value)}
|
|
placeholder="your@email.com"
|
|
required
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-hidden"
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">We'll reply to this address if we need more details.</p>
|
|
</div>
|
|
|
|
{/* Feedback Type — custom selector with descriptions */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground mb-1.5">
|
|
Feedback Type
|
|
</label>
|
|
<div className="relative">
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
role="combobox"
|
|
aria-expanded={typeDropdownOpen}
|
|
aria-haspopup="listbox"
|
|
aria-controls="feedback-type-listbox"
|
|
aria-activedescendant={highlightedIndex >= 0 ? `feedback-type-${highlightedIndex}` : undefined}
|
|
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
|
onKeyDown={handleDropdownKeyDown}
|
|
className={cn(
|
|
"w-full rounded-lg border border-border bg-card px-3 py-2 text-left flex items-center justify-between focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-hidden",
|
|
feedbackType ? "text-foreground" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
<span>{selectedType?.value ?? 'Select a type...'}</span>
|
|
<ChevronDown size={16} className={cn("transition-transform", typeDropdownOpen && "rotate-180")} />
|
|
</button>
|
|
{typeDropdownOpen && (
|
|
<div
|
|
ref={listboxRef}
|
|
id="feedback-type-listbox"
|
|
role="listbox"
|
|
className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden"
|
|
>
|
|
{FEEDBACK_TYPES.map((type, index) => (
|
|
<button
|
|
key={type.value}
|
|
id={`feedback-type-${index}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={feedbackType === type.value}
|
|
onClick={() => handleSelectType(type.value)}
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
className={cn(
|
|
"w-full text-left px-3 py-2.5 hover:bg-accent transition-colors",
|
|
feedbackType === type.value && "bg-accent",
|
|
highlightedIndex === index && feedbackType !== type.value && "bg-accent/50"
|
|
)}
|
|
>
|
|
<div className="text-sm font-medium text-foreground">{type.value}</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">{type.description}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message */}
|
|
<div>
|
|
<label htmlFor="feedback-message" className="block text-sm font-medium text-foreground mb-1.5">
|
|
Your Feedback
|
|
</label>
|
|
<textarea
|
|
id="feedback-message"
|
|
value={message}
|
|
onChange={e => setMessage(e.target.value)}
|
|
placeholder="Describe your feedback in detail..."
|
|
required
|
|
minLength={10}
|
|
rows={6}
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-hidden resize-y"
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{message.trim().length < 10
|
|
? `Minimum 10 characters (${message.trim().length}/10)`
|
|
: `${message.trim().length} characters`}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<div className="pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={!canSubmit || isSubmitting}
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-lg px-6 py-2.5 font-medium text-white transition-opacity",
|
|
canSubmit && !isSubmitting
|
|
? "bg-primary hover:brightness-110"
|
|
: "bg-primary opacity-50 cursor-not-allowed"
|
|
)}
|
|
>
|
|
<Send size={16} />
|
|
{isSubmitting ? 'Sending...' : 'Submit Feedback'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default FeedbackPage
|