Files
resolutionflow/frontend/src/pages/SurveyPage.tsx
chihlasm 5ff9a9d75e feat: replace all hardcoded orange rgba with blue rgba
Mechanical find-and-replace: rgba(249,115,22,...) → rgba(96,165,250,...)
across ~40 component and page files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:20:13 +00:00

737 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useRef, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PageMeta } from '@/components/common/PageMeta'
// ── 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 23 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: 'prioritization', type: 'rank', num: '14', text: 'Rank these factors by how much they influence your diagnostic order.', hint: 'What drives which theory you investigate first? Drag to reorder — most influential at the top.',
items: [
"Likelihood of being the root cause",
"How fast I can test or rule it out",
"Blast radius — impact if it's the problem",
"How recently something changed",
"Tool / access availability right now",
"Past experience with similar symptoms"
]},
]
},
{
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 [emailInput, setEmailInput] = useState('')
const [emailSending, setEmailSending] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [emailError, setEmailError] = useState('')
const [responseId, setResponseId] = useState<string | null>(null)
const navigate = useNavigate()
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 topRef = useRef<HTMLDivElement>(null)
const goSlide = (idx: number) => {
setCurrentSlide(idx)
}
// Scroll to top whenever the active slide changes — mobile browsers
// (especially iOS Safari) ignore window.scrollTo, so we use
// scrollIntoView on a ref at the top of the page as primary method
const isFirstRender = useRef(true)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
if (topRef.current) {
topRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [currentSlide])
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)
window.scrollTo({ top: 0, behavior: 'smooth' })
return
}
const errData = await res.json().catch(() => null)
throw new Error(errData?.detail || `Submission failed (${res.status})`)
}
const data = await res.json()
setResponseId(data.id)
setIsComplete(true)
window.scrollTo({ top: 0, behavior: 'smooth' })
} 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, var(--color-primary), transparent 70%)' }} />
<div className="absolute -bottom-32 left-0 h-[400px] w-[400px] rounded-full opacity-[0.02]" style={{ background: 'radial-gradient(circle, var(--color-accent), transparent 70%)' }} />
</div>
<div className="relative z-10 mx-auto max-w-[680px] px-4 sm:px-5">
<div className="text-center pt-20 sm: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(96, 165, 250, 0.10)' }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" 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 mb-3">
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
</p>
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-8">
You can safely close this browser window now.
</p>
<div className="card-flat p-4 sm:p-5 max-w-[400px] mx-auto text-center">
<p className="text-xs text-muted-foreground leading-relaxed">
Have feedback unrelated to the survey?{' '}
<a href="mailto:feedback@resolutionflow.com" className="text-primary hover:underline font-medium">
feedback@resolutionflow.com
</a>
</p>
</div>
</div>
</div>
</div>
)
}
return (
<div ref={topRef} className="min-h-screen bg-background text-foreground">
<PageMeta
title="Product Survey"
description="Help shape the future of ResolutionFlow by sharing your feedback"
/>
{/* Top bar */}
<div className="sticky top-0 z-50" style={{ background: 'rgba(26, 28, 35, 0.95)', borderBottom: '1px solid var(--color-border-default)' }}>
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-4 py-3 sm:px-5 sm:py-3.5">
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2 sm:gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline shrink-0">
<BrandLogo size="sm" />
<span className="hidden sm:inline">Resolution<span className="text-accent-text">Flow</span></span>
</a>
<div className="flex flex-1 items-center gap-2 sm:gap-2.5" style={{ maxWidth: '280px' }}>
<div className="flex-1 h-[3px] rounded-full overflow-hidden" style={{ background: 'var(--color-border)' }}>
<div className="h-full rounded-full bg-primary transition-[width] duration-500" style={{ width: `${progressPct}%` }} />
</div>
<span className="text-[11px] font-sans text-xs text-muted-foreground whitespace-nowrap tabular-nums">{answeredCount}/{TOTAL_QUESTIONS}</span>
</div>
</div>
</div>
<div className="relative z-10 mx-auto max-w-[680px] px-4 pb-20 sm:px-5 sm:pb-24">
{/* Hero — visible only on first slide */}
{currentSlide === 0 && !isComplete && (
<div className="text-center pt-10 pb-8 sm:pt-[72px] sm:pb-10 animate-fade-in-up">
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] sm:text-[11px] font-semibold font-sans text-xs uppercase tracking-widest mb-4 sm:mb-5" style={{ background: 'rgba(96, 165, 250, 0.10)', border: '1px solid rgba(96, 165, 250, 0.15)', color: 'var(--color-primary)' }}>
<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(24px,5vw,36px)] font-extrabold leading-tight mb-3">
Help Build an AI That<br/>Thinks Like <span className="text-accent-text">You</span>
</h1>
<p className="text-[14px] sm: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 flex-wrap justify-center gap-4 sm:gap-7 mt-4 sm:mt-5 text-[11px] sm: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="var(--color-primary)" 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="var(--color-primary)" 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="var(--color-primary)" 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-6 sm:mb-9">
{SLIDES.map((_, i) => (
<div
key={i}
className="flex-1 h-1 sm:h-[3px] rounded-full transition-colors duration-300"
style={{
background: i < currentSlide ? 'oklch(0.76 0.15 163)' : i === currentSlide ? 'linear-gradient(90deg, var(--color-primary), var(--color-brand-gradient-to))' : 'var(--color-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-6 sm:mt-7 gap-3">
{si > 0 ? (
<button onClick={() => goSlide(si - 1)} className="inline-flex items-center gap-2 px-4 py-2.5 sm:px-6 sm:py-3 rounded-lg 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-5 py-2.5 sm:px-6 sm:py-3 rounded-lg text-sm font-semibold bg-primary text-white transition-all duration-150 hover:brightness-110 active:scale-[0.98]">
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-5 py-2.5 sm:px-6 sm:py-3 rounded-lg text-sm font-semibold bg-primary text-white transition-all duration-150 hover:brightness-110 active:scale-[0.98] 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-10 sm: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="oklch(0.76 0.15 163)" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
</div>
<h2 className="font-heading text-2xl font-bold mb-2.5">Response Submitted!</h2>
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-6 sm:mb-8 leading-relaxed">
Your answers will directly shape how FlowPilot troubleshoots. Would you like a copy of your responses?
</p>
{/* Email a copy */}
<div className="card-flat p-4 sm:p-6 max-w-[420px] mx-auto mb-5">
<p className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground mb-3">
Email a copy to yourself
</p>
{!emailSent ? (
<div className="flex flex-col sm:flex-row gap-2">
<input
type="email"
value={emailInput}
onChange={e => setEmailInput(e.target.value)}
placeholder="your@email.com"
className="flex-1 rounded-[9px] px-3.5 py-2.5 text-sm text-foreground placeholder:text-text-muted focus:outline-hidden"
style={{ background: 'rgba(16, 17, 20, 0.6)', border: '1px solid var(--color-border-default)' }}
onFocus={e => { e.currentTarget.style.borderColor = 'var(--color-primary)' }}
onBlur={e => { e.currentTarget.style.borderColor = 'var(--color-border-default)' }}
disabled={emailSending}
/>
<button
onClick={async () => {
if (!emailInput.trim() || !responseId) return
setEmailSending(true)
setEmailError('')
try {
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const res = await fetch(`${apiUrl}/api/v1/survey/email-copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailInput.trim(), response_id: responseId }),
})
if (!res.ok) {
const err = await res.json().catch(() => null)
throw new Error(err?.detail || 'Failed to send')
}
setEmailSent(true)
} catch (err) {
setEmailError(err instanceof Error ? err.message : 'Failed to send email')
} finally {
setEmailSending(false)
}
}}
disabled={!emailInput.trim() || emailSending}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-[9px] text-sm font-semibold bg-primary text-white transition-all duration-150 hover:brightness-110 active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
{emailSending ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
Sending...
</>
) : 'Send'}
</button>
</div>
) : (
<div className="flex items-center justify-center gap-2 py-2 text-sm text-emerald-400">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg>
Email sent! Check your inbox.
</div>
)}
{emailError && (
<p className="text-xs text-rose-400 mt-2">{emailError}</p>
)}
</div>
{/* Copy + Finish buttons */}
<div className="flex gap-2.5 justify-center flex-wrap">
<button onClick={copyAll} className="inline-flex items-center gap-2 px-4 py-2.5 sm:px-5 rounded-lg text-sm font-semibold transition-all duration-150 text-muted-foreground 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"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy to Clipboard
</button>
<button
onClick={() => navigate('/survey/thank-you')}
className="inline-flex items-center gap-2 px-5 py-2.5 sm:px-6 rounded-lg text-sm font-semibold bg-primary text-white transition-all duration-150 hover:brightness-110 active:scale-[0.98]"
>
Finish
<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>
</div>
</div>
)}
</div>
</div>
)
}
// ── Sub-components ──
function ScenarioBox({ scenario }: { scenario: { title: string; symptom: string; details: string } }) {
return (
<div className="rounded-lg p-3.5 px-4 sm:p-4 sm:px-5 mb-4 text-[13px]" style={{ background: 'rgba(96, 165, 250, 0.04)', border: '1px solid color-mix(in srgb, var(--color-primary) 12%, transparent)' }}>
<div className="font-sans text-xs text-[10px] uppercase tracking-widest mb-2 font-semibold" style={{ color: 'var(--color-primary)' }}>{scenario.title}</div>
<div className="sm: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="sm: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="card-flat p-4 sm:p-7 mb-3 sm:mb-4 transition-[border-color] duration-200 focus-within:border-primary/25!">
<div className="font-sans text-xs text-[11px] mb-1.5 font-medium" style={{ color: 'var(--color-primary)' }}>Q{q.num}</div>
<div className="font-heading text-[14px] sm:text-[15px] font-semibold text-foreground leading-snug mb-1">{q.text}</div>
{q.hint && <div className="text-[12px] text-muted-foreground mb-3 sm:mb-4 leading-snug">{q.hint}</div>}
{!q.hint && <div className="mb-3 sm: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-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
style={{
background: answer === opt ? 'rgba(96, 165, 250, 0.10)' : 'rgba(16, 17, 20, 0.6)',
border: `1px solid ${answer === opt ? 'var(--color-primary)' : 'var(--color-border-default)'}`,
color: answer === opt ? 'var(--color-foreground)' : 'var(--color-muted-foreground)',
}}
>
<div className="w-[18px] h-[18px] rounded-full shrink-0 flex items-center justify-center transition-all duration-150 mt-0.5" style={{ border: `2px solid ${answer === opt ? 'var(--color-primary)' : 'var(--color-border-default)'}` }}>
{answer === opt && <div className="w-2 h-2 rounded-full" style={{ background: 'var(--color-primary)' }} />}
</div>
<span className="leading-snug">{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-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
style={{
background: selected ? 'rgba(96, 165, 250, 0.10)' : 'rgba(16, 17, 20, 0.6)',
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border-default)'}`,
color: selected ? 'var(--color-foreground)' : 'var(--color-muted-foreground)',
}}
>
<div className="w-[18px] h-[18px] rounded-[5px] shrink-0 flex items-center justify-center text-[11px] transition-all duration-150 mt-0.5" style={{ border: `2px solid ${selected ? 'var(--color-primary)' : 'var(--color-border-default)'}`, background: selected ? 'var(--color-primary)' : 'transparent', color: selected ? 'white' : 'transparent' }}>
{'\u2713'}
</div>
<span className="leading-snug">{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 sm:p-3.5 text-[13px] sm:text-sm text-foreground leading-relaxed resize-y transition-all duration-200 placeholder:text-text-muted focus:outline-hidden"
style={{
background: 'rgba(16, 17, 20, 0.6)',
border: '1px solid var(--color-border-default)',
}}
onFocus={e => { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.boxShadow = '0 0 0 3px rgba(96, 165, 250, 0.10)' }}
onBlur={e => { e.currentTarget.style.borderColor = 'var(--color-border-default)'; 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-sans text-xs text-2xl font-semibold mb-3" style={{ color: 'var(--color-primary)' }}>
{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-2 sm:h-1 rounded-full appearance-none cursor-pointer touch-none"
style={{
background: `linear-gradient(to right, var(--color-primary) 0%, var(--color-primary) ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, var(--color-border) ${((numVal - (q.min || 0)) / ((q.max || 10) - (q.min || 0))) * 100}%, var(--color-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-2.5 sm:gap-3 px-3 py-3 sm:px-4 sm:py-2.5 rounded-[9px] text-[13px] sm:text-sm transition-all duration-150 select-none"
style={{
background: overIdx === idx ? 'rgba(96, 165, 250, 0.10)' : 'rgba(16, 17, 20, 0.6)',
border: `1px solid ${overIdx === idx || draggingIdx === idx ? 'var(--color-primary)' : 'var(--color-border-default)'}`,
opacity: draggingIdx === idx ? 0.5 : 1,
cursor: 'grab',
color: 'var(--color-muted-foreground)',
}}
>
<div className="shrink-0 text-text-muted">
<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-sans text-xs text-[11px] font-semibold w-5 text-center shrink-0" style={{ color: 'var(--color-primary)' }}>{idx + 1}</div>
<div className="flex-1 leading-snug">{item}</div>
</div>
))}
</div>
)
}