feat: add invite token handling to survey page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
634
frontend/src/pages/SurveyPage.tsx
Normal file
634
frontend/src/pages/SurveyPage.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
|
||||
// ── Survey Data Types ──
|
||||
|
||||
type QuestionType = 'mc' | 'mc-multi' | 'range' | 'text' | 'rank'
|
||||
|
||||
interface SurveyQuestion {
|
||||
id: string
|
||||
type: QuestionType
|
||||
num: string
|
||||
text: string
|
||||
hint?: string
|
||||
options?: string[]
|
||||
items?: string[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
suffix?: string
|
||||
low_label?: string
|
||||
high_label?: string
|
||||
}
|
||||
|
||||
interface SurveySlideData {
|
||||
id: string
|
||||
questions: SurveyQuestion[]
|
||||
scenario?: {
|
||||
title: string
|
||||
symptom: string
|
||||
details: string
|
||||
}
|
||||
}
|
||||
|
||||
// ── Survey Questions (from reference HTML) ──
|
||||
|
||||
const SLIDES: SurveySlideData[] = [
|
||||
{
|
||||
id: 'prework',
|
||||
questions: [
|
||||
{ id: 'prereqs', type: 'mc-multi', num: '1', text: 'Before you start troubleshooting, what info do you need? (Select all that apply)', hint: 'What do you gather or verify before you even start diagnosing?',
|
||||
options: ["Who's affected (one user, group, everyone)", "What changed recently (patches, config, new software)", "Is there an existing/related ticket", "Client environment details (domain, VPN, on-prem vs. cloud)", "How long the issue has been happening", "User's exact error message or screenshot", "Whether the user has already tried anything", "RMM/monitoring status for the device or service"] },
|
||||
{ id: 'verify_fix', type: 'mc', num: '2', text: 'After you apply a fix, how do you verify it actually worked?',
|
||||
options: ["Have the user confirm it's working", "Test it myself from their machine or account", "Run the same diagnostic command that showed the failure", "Check monitoring/logs to confirm the service is healthy", "All of the above in sequence", "Fix it and move on — if they don't call back, it worked"] },
|
||||
{ id: 'steps_at_a_time', type: 'range', num: '3', text: 'When following a troubleshooting guide, how many steps do you prefer to see at once?', hint: '1 = show me one step at a time, 10 = give me the full picture upfront.',
|
||||
min: 1, max: 10, step: 1, suffix: ' steps', low_label: 'One at a time', high_label: 'Show me everything' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'philosophy',
|
||||
questions: [
|
||||
{ id: 'first_step', type: 'mc', num: '4', text: 'A vague ticket comes in: "Internet is down." What\'s your FIRST move?', hint: 'Before any technical check — what\'s the first mental step?',
|
||||
options: ["Check if it's one user or many (scope it)", "Look at monitoring / RMM dashboard", "Check for recent changes or maintenance", "Ask the user clarifying questions", "Ping / basic connectivity test", "Check if other tickets are related"] },
|
||||
{ id: 'junior_mistake', type: 'mc', num: '5', text: 'What\'s the most common mistake you see junior engineers make?',
|
||||
options: ["Jumping to a fix before understanding the problem", "Not checking scope (one user vs. many)", "Ignoring recent changes as a cause", "Googling the error instead of reading it", "Restarting things without checking logs first", "Not documenting what they tried"] },
|
||||
{ id: 'pivot', type: 'mc', num: '6', text: 'How do you decide when to stop pursuing one theory and pivot?',
|
||||
options: ["After 2–3 checks that don't support the theory", "Time-based — if I've spent 15+ min with no progress", "When I find evidence that contradicts the theory", "Gut feeling / experience", "When I run out of ideas on that path"] },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'scenario',
|
||||
scenario: {
|
||||
title: 'Incoming Ticket',
|
||||
symptom: 'Multiple users can\'t access \\\\fileserver\\shared',
|
||||
details: 'Started ~9 AM. Some users still can. No known recent changes. Server appears online.'
|
||||
},
|
||||
questions: [
|
||||
{ id: 'scenario_approach', type: 'text', num: '7', text: 'Walk through your first 3 diagnostic steps for this ticket.', hint: 'Include specific commands/tools, what you expect to see, and what a bad result tells you.' },
|
||||
{ id: 'scenario_deeper', type: 'text', num: '8', text: 'Server pings fine, you can RDP in. What do you check next on the server itself?', hint: 'Services, shares, permissions, event logs — what\'s your sequence?' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'commands',
|
||||
questions: [
|
||||
{ id: 'doc_pct', type: 'range', num: '9', text: 'Be honest: what percentage of your troubleshooting steps do you actually document in the ticket?', min: 0, max: 100, step: 10, suffix: '%', low_label: 'Almost none', high_label: 'Everything' },
|
||||
{ id: 'go_to_commands', type: 'text', num: '10', text: 'What are your top 3 go-to PowerShell commands or one-liners? Include exact syntax.', hint: "The ones you type from muscle memory — we're building FlowPilot's command library from real usage." },
|
||||
{ id: 'secret_weapon', type: 'text', num: '11', text: "Name a command, tool, or technique that junior engineers don't know about but saves you significant time.", hint: 'The secret weapon stuff. This is gold.' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tribal',
|
||||
questions: [
|
||||
{ id: 'gotcha', type: 'text', num: '12', text: 'Describe an issue where the obvious diagnosis was WRONG. What did everyone assume, and what was it actually?', hint: "These 'gotcha' patterns help FlowPilot warn engineers before they go down the wrong path." },
|
||||
{ id: 'hard_rules', type: 'mc-multi', num: '13', text: 'Which of these "rules" do you follow? (Select all that apply)',
|
||||
options: [
|
||||
"Always check recent changes before deep-diving",
|
||||
"Screenshot/document current state before making changes",
|
||||
"Never restart a service without checking logs first",
|
||||
"Verify the user's report before troubleshooting",
|
||||
"Check if it's a known issue / existing ticket first",
|
||||
"Test the fix, don't assume it worked",
|
||||
"Always have a rollback plan"
|
||||
]},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'ranking',
|
||||
questions: [
|
||||
{ id: 'domain_rank', type: 'rank', num: '14', text: 'Drag to rank: which technical domains should FlowPilot handle first?', hint: 'Most important at the top.',
|
||||
items: [
|
||||
"Windows Server / Active Directory",
|
||||
"Microsoft 365 / Exchange",
|
||||
"Networking (DNS, DHCP, VPN, Firewall)",
|
||||
"Security / Compliance",
|
||||
"Virtualization (Hyper-V, VMware)",
|
||||
"Backup & Disaster Recovery",
|
||||
"Cloud (Azure / AWS)",
|
||||
"Endpoint Management"
|
||||
]},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'flowpilot',
|
||||
questions: [
|
||||
{ id: 'detail_level', type: 'mc', num: '15', text: 'When an AI suggests a diagnostic step, how specific should it be?',
|
||||
options: [
|
||||
'High-level only ("Check DNS resolution")',
|
||||
'Moderate ("Run nslookup against the domain controller")',
|
||||
'Exact syntax ("nslookup hostname dc01.domain.local — verify IP matches expected")',
|
||||
'Exact syntax + explanation of WHY and what to look for'
|
||||
]},
|
||||
{ id: 'ai_personality', type: 'mc', num: '16', text: 'What would make an AI troubleshooting assistant feel like a useful colleague instead of a chatbot?',
|
||||
options: [
|
||||
"Suggests things I haven't thought of yet",
|
||||
"Knows the right diagnostic order (not just alphabetical)",
|
||||
"Challenges my assumptions constructively",
|
||||
"Includes real commands with exact syntax",
|
||||
"Explains WHY we check things in a certain order",
|
||||
"Doesn't waste my time with obvious stuff"
|
||||
]},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const TOTAL_QUESTIONS = SLIDES.reduce((n, s) => n + s.questions.length, 0)
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export default function SurveyPage() {
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<string, string | string[]>>(() => {
|
||||
// Initialize rank answers with default order
|
||||
const init: Record<string, string[]> = {}
|
||||
SLIDES.forEach(s => s.questions.forEach(q => {
|
||||
if (q.type === 'rank' && q.items) init[q.id] = [...q.items]
|
||||
}))
|
||||
return init
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('t')
|
||||
const [inviteName, setInviteName] = useState<string | null>(null)
|
||||
const [alreadyCompleted, setAlreadyCompleted] = useState(false)
|
||||
const [tokenLoading, setTokenLoading] = useState(!!token)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
fetch(`${apiUrl}/api/v1/survey/invite/${token}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.status === 'completed') setAlreadyCompleted(true)
|
||||
if (data?.name) setInviteName(data.name)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setTokenLoading(false))
|
||||
}, [token])
|
||||
|
||||
const setAnswer = useCallback((qid: string, value: string | string[]) => {
|
||||
setAnswers(prev => ({ ...prev, [qid]: value }))
|
||||
}, [])
|
||||
|
||||
const answeredCount = SLIDES.reduce((count, slide) => {
|
||||
return count + slide.questions.filter(q => {
|
||||
const a = answers[q.id]
|
||||
return a !== undefined && a !== '' && (!Array.isArray(a) || a.length > 0)
|
||||
}).length
|
||||
}, 0)
|
||||
const progressPct = Math.round((answeredCount / TOTAL_QUESTIONS) * 100)
|
||||
|
||||
const goSlide = (idx: number) => {
|
||||
setCurrentSlide(idx)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
setSubmitError('')
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
const res = await fetch(`${apiUrl}/api/v1/survey/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ respondent_name: inviteName || undefined, responses: answers, token: token || undefined }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
setAlreadyCompleted(true)
|
||||
return
|
||||
}
|
||||
const errData = await res.json().catch(() => null)
|
||||
throw new Error(errData?.detail || `Submission failed (${res.status})`)
|
||||
}
|
||||
setIsComplete(true)
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Submission failed. Try copying your responses instead.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const buildOutput = () => {
|
||||
let out = 'FLOWPILOT SURVEY RESPONSE\n========================\n\n'
|
||||
SLIDES.forEach(slide => {
|
||||
slide.questions.forEach(q => {
|
||||
out += `Q${q.num}. ${q.text}\n`
|
||||
const a = answers[q.id]
|
||||
if (Array.isArray(a)) out += a.map((v, i) => ` ${i + 1}. ${v}`).join('\n') + '\n'
|
||||
else if (a) out += ` ${a}\n`
|
||||
else out += ' (no answer)\n'
|
||||
out += '\n'
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
const copyAll = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildOutput())
|
||||
alert('Copied! Paste into an email and send.')
|
||||
} catch {
|
||||
alert('Copy failed — try selecting the text manually.')
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (alreadyCompleted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div className="absolute -top-32 right-0 h-[500px] w-[500px] rounded-full opacity-[0.03]" style={{ background: 'radial-gradient(circle, #06b6d4, transparent 70%)' }} />
|
||||
<div className="absolute -bottom-32 left-0 h-[400px] w-[400px] rounded-full opacity-[0.02]" style={{ background: 'radial-gradient(circle, #a855f7, transparent 70%)' }} />
|
||||
</div>
|
||||
<div className="relative z-10 mx-auto max-w-[680px] px-5">
|
||||
<div className="text-center pt-32 animate-fade-in-up">
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(6, 182, 212, 0.1)' }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed">
|
||||
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Atmosphere orbs */}
|
||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ top: '-200px', left: '-100px', width: '600px', height: '600px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, rgba(6, 182, 212, 0.03) 40%, transparent 70%)', filter: 'blur(80px)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ bottom: '-150px', right: '-100px', width: '500px', height: '500px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, rgba(139, 92, 246, 0.02) 40%, transparent 70%)', filter: 'blur(80px)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-50" style={{ backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: 'rgba(16, 17, 20, 0.85)', borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-5 py-3.5">
|
||||
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline">
|
||||
<BrandLogo size="sm" />
|
||||
<span>Resolution<span className="text-gradient-brand">Flow</span></span>
|
||||
</a>
|
||||
<div className="flex flex-1 items-center gap-2.5" style={{ maxWidth: '240px' }}>
|
||||
<div className="flex-1 h-[3px] rounded-full overflow-hidden" style={{ background: 'hsl(var(--border))' }}>
|
||||
<div className="h-full rounded-full bg-gradient-brand transition-[width] duration-500" style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<span className="text-[11px] font-label text-muted-foreground whitespace-nowrap tabular-nums">{answeredCount} of {TOTAL_QUESTIONS}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-[680px] px-5 pb-24">
|
||||
{/* Hero — visible only on first slide */}
|
||||
{currentSlide === 0 && !isComplete && (
|
||||
<div className="text-center pt-[72px] pb-10 animate-fade-in-up">
|
||||
<div className="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-[11px] font-semibold font-label uppercase tracking-widest mb-5" style={{ background: 'rgba(6, 182, 212, 0.1)', border: '1px solid rgba(6, 182, 212, 0.15)', color: '#06b6d4' }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
FlowPilot Research
|
||||
</div>
|
||||
<h1 className="font-heading text-[clamp(26px,5vw,36px)] font-extrabold leading-tight mb-3">
|
||||
Help Build an AI That<br/>Thinks Like <span className="text-gradient-brand">You</span>
|
||||
</h1>
|
||||
<p className="text-[15px] text-muted-foreground max-w-[500px] mx-auto leading-relaxed">
|
||||
We're building an AI assistant for MSP engineers. Your expertise shapes how it thinks. Takes about 5 minutes.
|
||||
</p>
|
||||
<div className="flex justify-center gap-7 mt-5 text-[12px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
~5 minutes
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Confidential
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
16 questions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step dots */}
|
||||
{!isComplete && (
|
||||
<div className="flex gap-1 mb-9">
|
||||
{SLIDES.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-[3px] rounded-full transition-colors duration-300"
|
||||
style={{
|
||||
background: i < currentSlide ? '#34d399' : i === currentSlide ? 'linear-gradient(90deg, #06b6d4, #22d3ee)' : 'hsl(var(--border))',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slides */}
|
||||
{!isComplete && SLIDES.map((slide, si) => (
|
||||
si === currentSlide && (
|
||||
<div key={slide.id} className="animate-fade-in-up">
|
||||
{slide.scenario && <ScenarioBox scenario={slide.scenario} />}
|
||||
{slide.questions.map(q => (
|
||||
<QuestionCard key={q.id} question={q} answer={answers[q.id]} setAnswer={setAnswer} />
|
||||
))}
|
||||
<div className="flex justify-between mt-7 gap-3">
|
||||
{si > 0 ? (
|
||||
<button onClick={() => goSlide(si - 1)} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold text-muted-foreground transition-all duration-150 hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
Back
|
||||
</button>
|
||||
) : <div />}
|
||||
{si < SLIDES.length - 1 ? (
|
||||
<button onClick={() => goSlide(si + 1)} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
|
||||
Next
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSubmit} disabled={isSubmitting} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed">
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
{!isSubmitting && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{submitError && (
|
||||
<div className="mt-4 p-3 rounded-lg text-sm text-rose-400" style={{ background: 'rgba(244, 63, 94, 0.1)', border: '1px solid rgba(244, 63, 94, 0.2)' }}>
|
||||
{submitError}
|
||||
<button onClick={copyAll} className="ml-3 underline text-muted-foreground hover:text-foreground">Copy responses instead</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Completion */}
|
||||
{isComplete && (
|
||||
<div className="text-center pt-16 animate-fade-in-up">
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)' }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Done — Thank You!</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-7 leading-relaxed">
|
||||
Your answers will directly shape how FlowPilot troubleshoots. We truly appreciate your time and expertise.
|
||||
</p>
|
||||
<div className="flex gap-2.5 justify-center flex-wrap">
|
||||
<button onClick={copyAll} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
|
||||
Copy Responses to Clipboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ──
|
||||
|
||||
function ScenarioBox({ scenario }: { scenario: { title: string; symptom: string; details: string } }) {
|
||||
return (
|
||||
<div className="rounded-[10px] p-4 px-5 mb-4 text-[13px]" style={{ background: 'linear-gradient(135deg, rgba(6, 182, 212, 0.06), rgba(139, 92, 246, 0.03))', border: '1px solid rgba(6, 182, 212, 0.12)' }}>
|
||||
<div className="font-label text-[10px] uppercase tracking-widest mb-2 font-semibold" style={{ color: '#06b6d4' }}>{scenario.title}</div>
|
||||
<div className="flex gap-2 mb-1">
|
||||
<span className="text-muted-foreground font-medium whitespace-nowrap">Symptom:</span>
|
||||
<span className="text-muted-foreground/80">{scenario.symptom}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground font-medium whitespace-nowrap">Details:</span>
|
||||
<span className="text-muted-foreground/80">{scenario.details}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQuestion; answer?: string | string[]; setAnswer: (id: string, val: string | string[]) => void }) {
|
||||
return (
|
||||
<div className="glass-card-static p-7 mb-4 transition-[border-color] duration-200 focus-within:!border-[rgba(6,182,212,0.25)]">
|
||||
<div className="font-label text-[11px] mb-1.5 font-medium" style={{ color: '#06b6d4' }}>Q{q.num}</div>
|
||||
<div className="font-heading text-[15px] font-semibold text-foreground leading-snug mb-1">{q.text}</div>
|
||||
{q.hint && <div className="text-[12px] text-muted-foreground mb-4 leading-snug">{q.hint}</div>}
|
||||
{!q.hint && <div className="mb-4" />}
|
||||
|
||||
{q.type === 'mc' && q.options && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{q.options.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => setAnswer(q.id, opt)}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-[9px] text-left text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: answer === opt ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${answer === opt ? '#06b6d4' : 'var(--glass-border)'}`,
|
||||
color: answer === opt ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
|
||||
}}
|
||||
>
|
||||
<div className="w-[18px] h-[18px] rounded-full flex-shrink-0 flex items-center justify-center transition-all duration-150" style={{ border: `2px solid ${answer === opt ? '#06b6d4' : 'var(--glass-border)'}` }}>
|
||||
{answer === opt && <div className="w-2 h-2 rounded-full" style={{ background: '#06b6d4' }} />}
|
||||
</div>
|
||||
<span>{opt}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'mc-multi' && q.options && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{q.options.map(opt => {
|
||||
const selected = Array.isArray(answer) && answer.includes(opt)
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {
|
||||
const current = Array.isArray(answer) ? answer : []
|
||||
setAnswer(q.id, selected ? current.filter(v => v !== opt) : [...current, opt])
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-[9px] text-left text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: selected ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${selected ? '#06b6d4' : 'var(--glass-border)'}`,
|
||||
color: selected ? 'hsl(var(--foreground))' : 'hsl(var(--muted-foreground))',
|
||||
}}
|
||||
>
|
||||
<div className="w-[18px] h-[18px] rounded-[5px] flex-shrink-0 flex items-center justify-center text-[11px] transition-all duration-150" style={{ border: `2px solid ${selected ? '#06b6d4' : 'var(--glass-border)'}`, background: selected ? '#06b6d4' : 'transparent', color: selected ? 'white' : 'transparent' }}>
|
||||
{'\u2713'}
|
||||
</div>
|
||||
<span>{opt}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === 'range' && (
|
||||
<RangeInput question={q} value={answer as string | undefined} onChange={(val) => setAnswer(q.id, val)} />
|
||||
)}
|
||||
|
||||
{q.type === 'text' && (
|
||||
<textarea
|
||||
value={(answer as string) || ''}
|
||||
onChange={e => setAnswer(q.id, e.target.value)}
|
||||
placeholder="Type your answer here..."
|
||||
className="w-full min-h-[100px] rounded-[9px] p-3.5 text-sm text-foreground leading-relaxed resize-y transition-all duration-200 placeholder:text-[#5a6170] focus:outline-none"
|
||||
style={{
|
||||
background: 'rgba(16, 17, 20, 0.6)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
}}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.4)'; e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6, 182, 212, 0.1)' }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = 'var(--glass-border)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{q.type === 'rank' && q.items && (
|
||||
<DragRank items={Array.isArray(answer) ? answer as string[] : q.items} onChange={(items) => setAnswer(q.id, items)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeInput({ question: q, value, onChange }: { question: SurveyQuestion; value?: string; onChange: (val: string) => void }) {
|
||||
const numVal = value ? parseInt(value) : q.min || 0
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="text-center font-label text-2xl font-semibold mb-3" style={{ color: '#06b6d4' }}>
|
||||
{numVal}{q.suffix || ''}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={q.min}
|
||||
max={q.max}
|
||||
step={q.step}
|
||||
value={numVal}
|
||||
onChange={e => onChange(e.target.value + (q.suffix || ''))}
|
||||
className="w-full h-1 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, hsl(var(--border)) ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, hsl(var(--border)) 100%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground mt-2.5">
|
||||
<span>{q.low_label}</span>
|
||||
<span>{q.high_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DragRank({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
|
||||
const dragItem = useRef<number | null>(null)
|
||||
const dragOverItem = useRef<number | null>(null)
|
||||
const [draggingIdx, setDraggingIdx] = useState<number | null>(null)
|
||||
const [overIdx, setOverIdx] = useState<number | null>(null)
|
||||
|
||||
const handleDragStart = (idx: number) => {
|
||||
dragItem.current = idx
|
||||
setDraggingIdx(idx)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault()
|
||||
dragOverItem.current = idx
|
||||
setOverIdx(idx)
|
||||
}
|
||||
|
||||
const handleDrop = () => {
|
||||
if (dragItem.current === null || dragOverItem.current === null) return
|
||||
const newItems = [...items]
|
||||
const [removed] = newItems.splice(dragItem.current, 1)
|
||||
newItems.splice(dragOverItem.current, 0, removed)
|
||||
onChange(newItems)
|
||||
dragItem.current = null
|
||||
dragOverItem.current = null
|
||||
setDraggingIdx(null)
|
||||
setOverIdx(null)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggingIdx(null)
|
||||
setOverIdx(null)
|
||||
}
|
||||
|
||||
// Touch support
|
||||
const touchItem = useRef<number | null>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleTouchStart = (idx: number) => {
|
||||
touchItem.current = idx
|
||||
setDraggingIdx(idx)
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (touchItem.current === null || !listRef.current) return
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
const elements = listRef.current.querySelectorAll<HTMLDivElement>('[data-rank-item]')
|
||||
elements.forEach((el, i) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (touch.clientY >= rect.top && touch.clientY <= rect.bottom) {
|
||||
setOverIdx(i)
|
||||
dragOverItem.current = i
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchItem.current !== null && dragOverItem.current !== null && touchItem.current !== dragOverItem.current) {
|
||||
const newItems = [...items]
|
||||
const [removed] = newItems.splice(touchItem.current, 1)
|
||||
newItems.splice(dragOverItem.current, 0, removed)
|
||||
onChange(newItems)
|
||||
}
|
||||
touchItem.current = null
|
||||
dragOverItem.current = null
|
||||
setDraggingIdx(null)
|
||||
setOverIdx(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={listRef} className="flex flex-col gap-1.5" onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
|
||||
{items.map((item, idx) => (
|
||||
<div
|
||||
key={item}
|
||||
data-rank-item
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={() => setOverIdx(null)}
|
||||
onTouchStart={() => handleTouchStart(idx)}
|
||||
className="flex items-center gap-3 px-4 py-2.5 rounded-[9px] text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: overIdx === idx ? 'rgba(6, 182, 212, 0.1)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${overIdx === idx || draggingIdx === idx ? '#06b6d4' : 'var(--glass-border)'}`,
|
||||
opacity: draggingIdx === idx ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0 text-[#5a6170]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>
|
||||
</div>
|
||||
<div className="font-label text-[11px] font-semibold w-5 text-center flex-shrink-0" style={{ color: '#06b6d4' }}>{idx + 1}</div>
|
||||
<div className="flex-1">{item}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user