* refactor: adopt shared Input/Textarea components across 15 files Replace 42 raw <input>/<textarea> elements with <Input>/<Textarea> from components/ui/. Consistent focus states, error handling, and styling across all form fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: replace hardcoded rgba/hex colors with Tailwind tokens - rgba(255,255,255,0.xx) → bg-white/[0.xx], border-white/[0.xx] - rgba(6,182,212,0.3) → border-primary/30 (focus states) - #0a0a0a → bg-background - Inline style hex colors → var(--color-primary), var(--color-brand-gradient-to) - 28 files updated, zero hardcoded rgba() patterns remaining Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add PageMeta to 16 pages for SEO and proper browser tab titles Public pages (Login, Register, Forgot/Reset Password, Verify Email, Survey Thank You) get descriptions for SEO. Authenticated pages (Dashboard, Flow Library, My Flows, Session History, AI Assistant, Account Settings, Step Library, My Shares, Feedback, Guides) get proper tab titles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add page transitions and staggered list animations - ViewTransitionOutlet: wraps Outlet with fade-in-up animation keyed to route path. Sidebar/topbar stay still, only content area animates. - StaggerList: reusable component that cascades children with incremental delay (50ms default). Pure CSS via @utility stagger-item. - Applied stagger to TreeGridView, MyTreesPage cards, SessionHistoryPage. - New stagger-fade-in keyframe in @theme block. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: ViewTransitionOutlet needs h-full for React Flow canvas The wrapper div broke the height chain needed by TreeEditorPage's h-full layout, causing React Flow canvas to collapse to zero height. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: main-content flex layout for tree editor + scrollable pages Main content area is now flex-col so the ViewTransitionOutlet wrapper gets an explicit computed height via flex-1 min-h-0. This makes h-full resolve correctly in the tree editor (React Flow canvas) while still allowing overflow-y-auto scrolling for normal pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve ESLint errors in Button and Skeleton components - Button: suppress react-refresh/only-export-components for buttonVariants re-export - Skeleton: replace empty interface with type alias, replace Math.random() with static widths array Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add PageMeta, animation classes, and layout fixes to remaining pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <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-gradient-brand text-white font-medium px-6 py-2.5 rounded-lg shadow-lg shadow-primary/20 hover:opacity-90 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 shadow-lg shadow-primary/20 transition-opacity",
|
|
canSubmit && !isSubmitting
|
|
? "bg-gradient-brand hover:opacity-90"
|
|
: "bg-gradient-brand opacity-50 cursor-not-allowed"
|
|
)}
|
|
>
|
|
<Send size={16} />
|
|
{isSubmitting ? 'Sending...' : 'Submit Feedback'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default FeedbackPage
|