chore: archive old plan docs + add survey foundation files
Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
32
backend/alembic/versions/046_add_survey_responses_table.py
Normal file
32
backend/alembic/versions/046_add_survey_responses_table.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Add survey_responses table.
|
||||
|
||||
Revision ID: 046
|
||||
Revises: 045
|
||||
Create Date: 2026-03-04
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision: str = "046"
|
||||
down_revision: str = "045"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"survey_responses",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("respondent_name", sa.String(255), nullable=True),
|
||||
sa.Column("responses", JSONB, nullable=False),
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("user_agent", sa.Text, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("survey_responses")
|
||||
536
docs/plans/survey-implementation-plan.md
Normal file
536
docs/plans/survey-implementation-plan.md
Normal file
@@ -0,0 +1,536 @@
|
||||
# FlowPilot Survey — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a public-facing survey page at `/survey` that collects FlowPilot research responses from senior MSP engineers and emails the results to Michael. The survey is unauthenticated (no login required), uses the existing ResolutionFlow design system, and has a backend endpoint to receive and email submissions via the existing Resend infrastructure.
|
||||
|
||||
**Architecture:** Static React page (public route, no auth) → `POST /api/v1/survey/submit` (unauthenticated, rate-limited) → saves to DB → sends notification email via existing `EmailService` + Resend. No confirmation email to respondent (they may not provide an email).
|
||||
|
||||
**Design doc reference:** The survey content and question structure is defined in the HTML file at `frontend/public/survey-reference.html` (copy the uploaded `survey.html` there for reference). The page must be rebuilt as a proper React component using the existing design system — NOT the inline-styled HTML.
|
||||
|
||||
**Tech Stack:** FastAPI backend, SQLAlchemy 2.0 (async), Alembic, Pydantic v2. React 19, TypeScript, Tailwind CSS, shadcn/ui frontend.
|
||||
|
||||
---
|
||||
|
||||
## Reference File
|
||||
|
||||
Before starting, copy the survey HTML reference file into the project:
|
||||
|
||||
```bash
|
||||
cp /path/to/survey.html docs/reference/flowpilot-survey-reference.html
|
||||
```
|
||||
|
||||
This file contains the exact questions, options, types, and flow. Use it as the source of truth for survey content. The visual design should NOT copy the inline styles — instead, use the ResolutionFlow design system (Tailwind + shadcn/ui + brand tokens).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend
|
||||
|
||||
### Task 1: Database Model — `SurveyResponse`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/survey_response.py`
|
||||
- Modify: `backend/app/models/__init__.py` (add import + `__all__` entry)
|
||||
|
||||
**Step 1: Create the model file**
|
||||
|
||||
```python
|
||||
# backend/app/models/survey_response.py
|
||||
"""FlowPilot survey response storage."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SurveyResponse(Base):
|
||||
__tablename__ = "survey_responses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
respondent_name = Column(String(255), nullable=True) # Optional — we may already know who they are
|
||||
responses = Column(JSONB, nullable=False) # Full survey answers as JSON
|
||||
ip_address = Column(String(45), nullable=True) # For rate limiting / dedup
|
||||
user_agent = Column(Text, nullable=True) # Browser info
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
```
|
||||
|
||||
**Step 2: Add to models `__init__.py`**
|
||||
|
||||
Add import and `__all__` entry following the existing pattern in the file.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/survey_response.py backend/app/models/__init__.py
|
||||
git commit -m "feat: add SurveyResponse model for FlowPilot research"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Database Migration
|
||||
|
||||
**Files:**
|
||||
- Create: new Alembic migration
|
||||
|
||||
**Step 1: Generate migration**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "add survey_responses table"
|
||||
```
|
||||
|
||||
**Step 2: Review the generated migration** — verify it creates `survey_responses` with the correct columns.
|
||||
|
||||
**Step 3: Run migration**
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/alembic/versions/
|
||||
git commit -m "feat: add survey_responses migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Pydantic Schema
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/schemas/survey.py`
|
||||
|
||||
**Step 1: Create schema**
|
||||
|
||||
```python
|
||||
# backend/app/schemas/survey.py
|
||||
"""Schemas for FlowPilot survey submission."""
|
||||
from typing import Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SurveySubmission(BaseModel):
|
||||
"""Incoming survey response from the public form."""
|
||||
respondent_name: Optional[str] = Field(None, max_length=255)
|
||||
responses: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Question ID → answer mapping. Values can be strings, lists, or numbers.",
|
||||
)
|
||||
|
||||
|
||||
class SurveySubmissionResponse(BaseModel):
|
||||
"""Response after successful submission."""
|
||||
message: str = "Thank you for your response!"
|
||||
id: str
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/survey.py
|
||||
git commit -m "feat: add survey submission schemas"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: API Endpoint
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/api/endpoints/survey.py`
|
||||
- Modify: `backend/app/api/router.py` (register the new router)
|
||||
|
||||
**Step 1: Create the endpoint**
|
||||
|
||||
```python
|
||||
# backend/app/api/endpoints/survey.py
|
||||
"""Public survey submission endpoint. No authentication required."""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.email import EmailService
|
||||
from app.core.rate_limit import limiter
|
||||
from app.models.survey_response import SurveyResponse
|
||||
from app.schemas.survey import SurveySubmission, SurveySubmissionResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["survey"])
|
||||
|
||||
|
||||
@router.post("/survey/submit", response_model=SurveySubmissionResponse)
|
||||
@limiter.limit("3/hour")
|
||||
async def submit_survey(
|
||||
request: Request,
|
||||
data: SurveySubmission,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Accept a public survey submission. No auth required.
|
||||
|
||||
Rate limited to 3/hour per IP to prevent spam.
|
||||
Saves to DB and sends notification email to configured address.
|
||||
"""
|
||||
# Get client info
|
||||
ip = request.client.host if request.client else None
|
||||
ua = request.headers.get("user-agent", "")
|
||||
|
||||
# Save to database
|
||||
response = SurveyResponse(
|
||||
respondent_name=data.respondent_name,
|
||||
responses=data.responses,
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
db.add(response)
|
||||
await db.flush()
|
||||
|
||||
# Send notification email (fire-and-forget, don't fail the request)
|
||||
try:
|
||||
await EmailService.send_survey_notification_email(
|
||||
to_email=settings.FEEDBACK_EMAIL, # Reuse existing feedback email config
|
||||
respondent_name=data.respondent_name,
|
||||
responses=data.responses,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send survey notification email")
|
||||
|
||||
return SurveySubmissionResponse(id=str(response.id))
|
||||
```
|
||||
|
||||
**Step 2: Register in router.py**
|
||||
|
||||
Add to `backend/app/api/router.py` following the existing pattern. Import and include the survey router. **IMPORTANT:** This endpoint does NOT use `get_current_active_user` — it's public. Make sure the route is included without any auth dependency prefix.
|
||||
|
||||
The survey route should be registered similarly to how the auth routes are registered (no auth prefix), e.g.:
|
||||
```python
|
||||
from app.api.endpoints import survey
|
||||
api_router.include_router(survey.router, prefix="")
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/endpoints/survey.py backend/app/api/router.py
|
||||
git commit -m "feat: add public survey submission endpoint with rate limiting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Email Notification
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/email.py` (add new static method to `EmailService`)
|
||||
|
||||
**Step 1: Add `send_survey_notification_email` to `EmailService`**
|
||||
|
||||
Follow the exact same pattern as `send_feedback_email()` — it's a notification to Michael with the response content. Use the same dark-themed HTML template style as existing emails.
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
async def send_survey_notification_email(
|
||||
to_email: str,
|
||||
respondent_name: str | None,
|
||||
responses: dict,
|
||||
) -> bool:
|
||||
"""Send survey response notification. Fire-and-forget."""
|
||||
if not settings.email_enabled:
|
||||
logger.warning("Email not sent — RESEND_API_KEY not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
from datetime import datetime, timezone
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
name_display = respondent_name or "Anonymous"
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
subject = f"[FlowPilot Survey] New Response from {name_display} — {date_str}"
|
||||
|
||||
# Build response summary as HTML
|
||||
rows_html = ""
|
||||
for q_id, answer in responses.items():
|
||||
if isinstance(answer, list):
|
||||
answer_display = "<br>".join(f"• {item}" for item in answer)
|
||||
else:
|
||||
answer_display = str(answer)
|
||||
rows_html += f"""
|
||||
<tr>
|
||||
<td style="padding: 10px 14px; border-bottom: 1px solid #27272a; color: #818cf8; font-weight: 600; vertical-align: top; width: 120px; font-size: 13px;">{q_id}</td>
|
||||
<td style="padding: 10px 14px; border-bottom: 1px solid #27272a; color: #e4e4e7; font-size: 14px; white-space: pre-wrap;">{answer_display}</td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html><body style="margin:0; padding:0; background-color:#09090b; font-family:Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#09090b; padding:40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#18181b; border-radius:12px; border:1px solid #27272a;">
|
||||
<tr><td style="padding:32px 32px 20px;">
|
||||
<h1 style="margin:0 0 4px; font-size:20px; color:#f4f4f5;">FlowPilot Survey Response</h1>
|
||||
<p style="margin:0; font-size:13px; color:#71717a;">From: {name_display} • {date_str}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 32px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #27272a; border-radius:8px; overflow:hidden;">
|
||||
{rows_html}
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 24px;">
|
||||
<p style="margin:0; font-size:12px; color:#52525b;">This response has been saved to the survey_responses table.</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
resend.Emails.send({
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [to_email],
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
})
|
||||
logger.info("Survey notification sent for respondent: %s", name_display)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send survey notification email")
|
||||
return False
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/core/email.py
|
||||
git commit -m "feat: add survey notification email to EmailService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: CORS — Allow Public Survey Endpoint
|
||||
|
||||
**Files:**
|
||||
- Verify: `backend/app/main.py` CORS configuration
|
||||
|
||||
The survey endpoint needs to accept POST requests from the frontend. Since both frontend and backend are on the same domain (`resolutionflow.com` / `api.resolutionflow.com`), this should already work with existing CORS config. **Verify** that the existing CORS middleware allows the frontend origin. No changes should be needed, but confirm.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Frontend
|
||||
|
||||
### Task 7: Survey Page Component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/pages/SurveyPage.tsx`
|
||||
|
||||
**CRITICAL:** This is a public page. It must NOT import or depend on `authStore`, `ProtectedRoute`, or any authenticated API client. It uses its own direct `fetch` or a standalone axios call to `POST /api/v1/survey/submit`.
|
||||
|
||||
**Design requirements:**
|
||||
- Use the existing ResolutionFlow design system: Tailwind CSS classes, shadcn/ui components (`Card`, `Button`, `Label`, `Textarea`, `Slider`, `Badge`, `Progress`)
|
||||
- Dark theme by default (the page won't inherit app shell's theme toggle — hardcode `dark` class on root or use dark-only styles)
|
||||
- Fonts: Plus Jakarta Sans for headings, Inter for body (already in `index.html`)
|
||||
- Brand gradient: `bg-gradient-brand` or `from-[#818cf8] to-[#a78bfa]`
|
||||
- Use `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`, `border-border` tokens
|
||||
- Responsive (works on mobile — engineers might fill this out on their phone)
|
||||
|
||||
**Component structure:**
|
||||
|
||||
```
|
||||
SurveyPage
|
||||
├── SurveyHero (title, description, stats)
|
||||
├── SurveyProgress (step dots + progress bar)
|
||||
├── SurveySlide (rendered per active slide)
|
||||
│ ├── MultipleChoice (single select)
|
||||
│ ├── MultipleChoiceMulti (multi select)
|
||||
│ ├── RangeSlider
|
||||
│ ├── TextAnswer (textarea)
|
||||
│ ├── DragRank (drag-to-reorder list)
|
||||
│ └── ScenarioBox (ticket simulation display)
|
||||
├── SlideNavigation (back/next buttons)
|
||||
└── SurveyComplete (thank you + submit)
|
||||
```
|
||||
|
||||
**Question data:** Extract the `SLIDES` array from the reference HTML file (`docs/reference/flowpilot-survey-reference.html`). Port it to a TypeScript constant with proper types:
|
||||
|
||||
```typescript
|
||||
type QuestionType = 'mc' | 'mc-multi' | 'range' | 'text' | 'rank';
|
||||
|
||||
interface SurveyQuestion {
|
||||
id: string;
|
||||
type: QuestionType;
|
||||
num: string;
|
||||
text: string;
|
||||
hint?: string;
|
||||
options?: string[]; // for mc, mc-multi
|
||||
items?: string[]; // for rank
|
||||
min?: number; // for range
|
||||
max?: number;
|
||||
step?: number;
|
||||
suffix?: string;
|
||||
low_label?: string;
|
||||
high_label?: string;
|
||||
}
|
||||
|
||||
interface SurveySlide {
|
||||
id: string;
|
||||
questions: SurveyQuestion[];
|
||||
scenario?: {
|
||||
title: string;
|
||||
symptom: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**State management:** Use local React state (`useState` / `useReducer`) — do NOT create a Zustand store for this. It's a self-contained page.
|
||||
|
||||
**Submission flow:**
|
||||
1. User clicks "Submit" on last slide
|
||||
2. Show loading state on button
|
||||
3. POST to `${VITE_API_URL}/api/v1/survey/submit` with `{ respondent_name: null, responses: answersObject }`
|
||||
4. On success → show completion screen
|
||||
5. On error → show error toast, keep form populated for retry
|
||||
6. Also offer "Copy to Clipboard" as fallback if submission fails
|
||||
|
||||
**The drag-and-drop ranking component:**
|
||||
- Use native HTML5 drag events (same approach as the reference HTML)
|
||||
- Support touch events for mobile
|
||||
- Show numbered positions that update on reorder
|
||||
- Use `border-border` for item borders, `bg-accent` for drag-over state
|
||||
|
||||
**Step 1: Create `frontend/src/pages/SurveyPage.tsx`**
|
||||
|
||||
Build the complete page as a single file component (it's a standalone page, not reused elsewhere). Aim for ~400-600 lines. Use shadcn components where they fit (Button, Card, Progress, Textarea, Slider, Badge), custom Tailwind for the rest.
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/SurveyPage.tsx
|
||||
git commit -m "feat: add SurveyPage component for FlowPilot research"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Public Route
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/router.tsx`
|
||||
|
||||
**Step 1: Add public route for `/survey`**
|
||||
|
||||
Add as a top-level route (same level as `/login`, `/register`, `/share/:shareToken` — NOT inside the `ProtectedRoute` wrapper):
|
||||
|
||||
```typescript
|
||||
import SurveyPage from '@/pages/SurveyPage'
|
||||
|
||||
// ... in the router array, at the top level alongside login/register:
|
||||
{
|
||||
path: '/survey',
|
||||
element: <SurveyPage />,
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
```
|
||||
|
||||
**IMPORTANT:** This route must be OUTSIDE the `ProtectedRoute` parent. It must not require authentication.
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
**Step 3: Verify route works**
|
||||
|
||||
Navigate to `http://localhost:5173/survey` — should render the survey without any login redirect.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/router.tsx
|
||||
git commit -m "feat: add public /survey route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Nginx Catch-All Verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `frontend/nginx.conf`
|
||||
|
||||
The existing nginx config should already have a `try_files $uri $uri/ /index.html` catch-all that sends unknown paths to the React router. Verify that `/survey` will be caught by this rule and served correctly. No changes should be needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Polish & Testing
|
||||
|
||||
### Task 10: Backend Test
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/tests/test_survey.py`
|
||||
|
||||
Write a test that:
|
||||
1. POSTs a valid survey submission to `/api/v1/survey/submit` (no auth headers)
|
||||
2. Verifies 200 response with `id` field
|
||||
3. Verifies the response was saved to the database
|
||||
4. Tests rate limiting (4th request within an hour returns 429)
|
||||
5. Tests validation (empty `responses` dict returns 422)
|
||||
|
||||
Follow the existing test patterns in `backend/tests/test_auth.py`.
|
||||
|
||||
**Step 1: Create test file**
|
||||
|
||||
**Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
cd backend && pytest tests/test_survey.py -v
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/tests/test_survey.py
|
||||
git commit -m "test: add survey submission endpoint tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Mobile Responsiveness Check
|
||||
|
||||
Verify the survey page renders correctly at these breakpoints:
|
||||
- Desktop: 1024px+
|
||||
- Tablet: 768px
|
||||
- Mobile: 375px
|
||||
|
||||
The drag-and-drop ranking must work on touch devices. If shadcn's `Slider` component doesn't render well on mobile, use a custom range input with Tailwind styling.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Tasks | New Files | Modified Files |
|
||||
|-------|-------|-----------|----------------|
|
||||
| Backend | Model, migration, schema, endpoint, email | `models/survey_response.py`, `schemas/survey.py`, `api/endpoints/survey.py`, migration | `models/__init__.py`, `api/router.py`, `core/email.py` |
|
||||
| Frontend | Survey page, public route | `pages/SurveyPage.tsx` | `router.tsx` |
|
||||
| Testing | Backend test, mobile check | `tests/test_survey.py` | — |
|
||||
|
||||
**Total estimated effort:** 3-4 hours for Claude Code execution.
|
||||
|
||||
**Key constraints:**
|
||||
- `/survey` route is PUBLIC — no authentication
|
||||
- Rate limit: 3 submissions per hour per IP
|
||||
- Email notification uses existing Resend + `FEEDBACK_EMAIL` config
|
||||
- Design uses existing Tailwind/shadcn design system, NOT the inline CSS from the reference HTML
|
||||
- Must work on mobile (engineers may fill this out on their phone)
|
||||
- The reference HTML at `docs/reference/flowpilot-survey-reference.html` is the source of truth for question content
|
||||
682
docs/plans/survey.html
Normal file
682
docs/plans/survey.html
Normal file
@@ -0,0 +1,682 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowPilot Survey — ResolutionFlow</title>
|
||||
<meta name="description" content="Help shape FlowPilot, an AI assistant built for MSP engineers. 5-minute survey.">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--gradient-start: #818cf8;
|
||||
--gradient-mid: #9389fa;
|
||||
--gradient-end: #a78bfa;
|
||||
--bg-deep: #050507;
|
||||
--bg-dark: #09090b;
|
||||
--bg-card: #111114;
|
||||
--bg-input: #0c0c0f;
|
||||
--border-subtle: #1e1e24;
|
||||
--border-hover: #2a2a32;
|
||||
--border-focus: #818cf8;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #52525b;
|
||||
--text-hint: #3f3f46;
|
||||
--accent: #818cf8;
|
||||
--accent-dim: rgba(129, 140, 248, 0.1);
|
||||
--accent-glow: rgba(129, 140, 248, 0.06);
|
||||
--success: #34d399;
|
||||
--success-dim: rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Ambient ── */
|
||||
.ambient { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
|
||||
.orb { position: absolute; border-radius: 50%; filter: blur(140px); opacity: 0.05; }
|
||||
.orb-1 { width: 600px; height: 600px; background: var(--gradient-start); top: -200px; left: -100px; animation: d1 25s ease-in-out infinite; }
|
||||
.orb-2 { width: 500px; height: 500px; background: var(--gradient-end); bottom: -150px; right: -100px; animation: d2 30s ease-in-out infinite; }
|
||||
@keyframes d1 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(40px,50px); } }
|
||||
@keyframes d2 { 0%,100% { transform: translate(0,0); } 50% { transform: translate(-30px,-40px); } }
|
||||
|
||||
.grid-pattern {
|
||||
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||
background-image: linear-gradient(rgba(129,140,248,0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(129,140,248,0.02) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
mask-image: radial-gradient(ellipse 70% 50% at 50% 30%, black, transparent);
|
||||
}
|
||||
|
||||
/* ── Layout ── */
|
||||
.container { position: relative; z-index: 1; max-width: 680px; margin: 0 auto; padding: 0 20px 100px; }
|
||||
|
||||
/* ── Top Bar ── */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
|
||||
background: rgba(5,5,7,0.85); border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 14px 20px; margin: 0 -20px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
.topbar-brand { display: flex; align-items: center; gap: 10px; font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 700; font-size: 14px; color: var(--text-secondary); white-space: nowrap; text-decoration: none; }
|
||||
.topbar-logo { width: 28px; height: 28px; background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 14px; color: white; font-weight: 800; flex-shrink: 0; }
|
||||
.topbar-brand .flow { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||
.progress-wrap { flex: 1; max-width: 240px; display: flex; align-items: center; gap: 10px; }
|
||||
.progress-bar { flex: 1; height: 3px; background: var(--border-subtle); border-radius: 2px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--gradient-start), var(--gradient-end)); border-radius: 2px; transition: width 0.5s ease; width: 0%; }
|
||||
.progress-text { font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
/* ── Hero ── */
|
||||
.hero { text-align: center; padding: 72px 0 40px; }
|
||||
.hero-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 14px; border-radius: 100px; background: var(--accent-dim); border: 1px solid rgba(129,140,248,0.15); font-size: 11px; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 20px; }
|
||||
.hero h1 { font-family: 'Plus Jakarta Sans', sans-serif; font-size: clamp(26px, 5vw, 36px); font-weight: 800; line-height: 1.2; margin-bottom: 12px; }
|
||||
.hero h1 span { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||
.hero p { font-size: 15px; color: var(--text-secondary); max-width: 500px; margin: 0 auto; line-height: 1.65; }
|
||||
.hero-stats { display: flex; justify-content: center; gap: 28px; margin-top: 20px; font-size: 12px; color: var(--text-muted); }
|
||||
.hero-stats span { display: flex; align-items: center; gap: 5px; }
|
||||
.hero-stats svg { width: 13px; height: 13px; stroke: var(--accent); fill: none; stroke-width: 2; }
|
||||
|
||||
/* ── Steps indicator ── */
|
||||
.steps-bar { display: flex; gap: 4px; margin-bottom: 36px; }
|
||||
.step-dot { flex: 1; height: 3px; border-radius: 2px; background: var(--border-subtle); transition: background 0.3s; }
|
||||
.step-dot.done { background: var(--success); }
|
||||
.step-dot.active { background: linear-gradient(90deg, var(--gradient-start), var(--gradient-end)); }
|
||||
|
||||
/* ── Form Slides ── */
|
||||
.slide { display: none; animation: fadeIn 0.35s ease; }
|
||||
.slide.active { display: block; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* ── Question Card ── */
|
||||
.q-card {
|
||||
background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: 14px;
|
||||
padding: 28px; margin-bottom: 16px; transition: border-color 0.2s;
|
||||
}
|
||||
.q-card:focus-within { border-color: rgba(129,140,248,0.25); }
|
||||
.q-num { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--accent); margin-bottom: 6px; font-weight: 500; }
|
||||
.q-text { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 15px; font-weight: 600; color: var(--text-primary); line-height: 1.5; margin-bottom: 4px; }
|
||||
.q-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 16px; line-height: 1.5; }
|
||||
|
||||
/* ── MC Options ── */
|
||||
.mc-options { display: flex; flex-direction: column; gap: 8px; }
|
||||
.mc-opt {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 16px; border-radius: 9px;
|
||||
border: 1px solid var(--border-subtle); background: var(--bg-input);
|
||||
cursor: pointer; transition: all 0.15s; font-size: 14px; color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.mc-opt:hover { border-color: var(--border-hover); color: var(--text-primary); }
|
||||
.mc-opt.selected { border-color: var(--accent); background: var(--accent-dim); color: var(--text-primary); }
|
||||
.mc-radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border-subtle); flex-shrink: 0; transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
|
||||
.mc-opt.selected .mc-radio { border-color: var(--accent); }
|
||||
.mc-opt.selected .mc-radio::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.mc-check { width: 18px; height: 18px; border-radius: 5px; border: 2px solid var(--border-subtle); flex-shrink: 0; transition: all 0.15s; display: flex; align-items: center; justify-content: center; font-size: 11px; color: transparent; }
|
||||
.mc-opt.selected .mc-check { border-color: var(--accent); background: var(--accent); color: white; }
|
||||
|
||||
/* ── Range Slider ── */
|
||||
.range-wrap { padding: 8px 0; }
|
||||
.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-muted); margin-bottom: 10px; }
|
||||
.range-val { text-align: center; font-family: 'JetBrains Mono', monospace; font-size: 24px; font-weight: 600; color: var(--accent); margin-bottom: 12px; }
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none; width: 100%; height: 4px; border-radius: 2px;
|
||||
background: var(--border-subtle); outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
||||
cursor: pointer; border: 3px solid var(--bg-card); box-shadow: 0 0 0 1px rgba(129,140,248,0.3);
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
|
||||
cursor: pointer; border: 3px solid var(--bg-card);
|
||||
}
|
||||
|
||||
/* ── Textarea ── */
|
||||
textarea {
|
||||
width: 100%; min-height: 100px; background: var(--bg-input); border: 1px solid var(--border-subtle);
|
||||
border-radius: 9px; padding: 14px 16px; color: var(--text-primary); font-family: 'Inter', sans-serif;
|
||||
font-size: 14px; line-height: 1.6; resize: vertical; transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
textarea::placeholder { color: var(--text-hint); }
|
||||
|
||||
/* ── Drag Ranking ── */
|
||||
.rank-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.rank-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 16px; border-radius: 9px;
|
||||
border: 1px solid var(--border-subtle); background: var(--bg-input);
|
||||
cursor: grab; transition: all 0.15s; font-size: 14px; color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.rank-item:active { cursor: grabbing; }
|
||||
.rank-item.dragging { opacity: 0.5; border-color: var(--accent); }
|
||||
.rank-item.drag-over { border-color: var(--accent); background: var(--accent-dim); }
|
||||
.rank-num { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--accent); font-weight: 600; width: 20px; text-align: center; flex-shrink: 0; }
|
||||
.rank-grip { color: var(--text-hint); flex-shrink: 0; display: flex; align-items: center; }
|
||||
.rank-label { flex: 1; }
|
||||
|
||||
/* ── Scenario Box ── */
|
||||
.scenario-box {
|
||||
background: linear-gradient(135deg, rgba(129,140,248,0.06), rgba(167,139,250,0.03));
|
||||
border: 1px solid rgba(129,140,248,0.12); border-radius: 10px;
|
||||
padding: 16px 20px; margin-bottom: 16px; font-size: 13px;
|
||||
}
|
||||
.scenario-box .sc-label { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px; font-weight: 600; }
|
||||
.scenario-box .sc-row { display: flex; gap: 8px; margin-bottom: 4px; }
|
||||
.scenario-box .sc-key { color: var(--text-muted); font-weight: 500; white-space: nowrap; }
|
||||
.scenario-box .sc-val { color: var(--text-secondary); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.nav-btns { display: flex; justify-content: space-between; margin-top: 28px; gap: 12px; }
|
||||
.btn {
|
||||
font-family: 'Inter', sans-serif; font-size: 14px; font-weight: 600; padding: 12px 24px;
|
||||
border-radius: 9px; border: none; cursor: pointer; transition: all 0.2s;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
|
||||
.btn-accent { background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); color: white; }
|
||||
.btn-accent:hover { opacity: 0.9; transform: translateY(-1px); }
|
||||
.btn-accent:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
.btn-success { background: var(--success-dim); border: 1px solid rgba(52,211,153,0.3); color: var(--success); }
|
||||
.btn-success:hover { background: rgba(52,211,153,0.15); }
|
||||
|
||||
/* ── Completion ── */
|
||||
.complete { display: none; text-align: center; padding: 60px 0; animation: fadeIn 0.4s ease; }
|
||||
.complete.active { display: block; }
|
||||
.complete-icon { width: 64px; height: 64px; margin: 0 auto 20px; background: var(--success-dim); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.complete-icon svg { width: 28px; height: 28px; stroke: var(--success); fill: none; stroke-width: 2.5; }
|
||||
.complete h2 { font-family: 'Plus Jakarta Sans', sans-serif; font-size: 24px; font-weight: 700; margin-bottom: 10px; }
|
||||
.complete p { color: var(--text-secondary); font-size: 14px; max-width: 440px; margin: 0 auto 28px; line-height: 1.65; }
|
||||
.complete-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast { position: fixed; bottom: 20px; right: 20px; z-index: 200; padding: 10px 18px; border-radius: 8px; font-size: 13px; font-weight: 500; transform: translateY(80px); opacity: 0; transition: all 0.3s ease; background: var(--success-dim); border: 1px solid rgba(52,211,153,0.3); color: var(--success); }
|
||||
.toast.show { transform: translateY(0); opacity: 1; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.container { padding: 0 16px 80px; }
|
||||
.topbar { padding: 12px 16px; margin: 0 -16px; }
|
||||
.hero { padding: 56px 0 28px; }
|
||||
.hero-stats { flex-wrap: wrap; gap: 12px; }
|
||||
.q-card { padding: 20px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="ambient"><div class="orb orb-1"></div><div class="orb orb-2"></div></div>
|
||||
<div class="grid-pattern"></div>
|
||||
|
||||
<div class="container">
|
||||
<!-- Top Bar -->
|
||||
<div class="topbar">
|
||||
<a class="topbar-brand" href="https://resolutionflow.com" target="_blank">
|
||||
<div class="topbar-logo">R</div>
|
||||
Resolution<span class="flow">Flow</span>
|
||||
</a>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span class="progress-text" id="progressText">0 of 16</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="hero" id="heroSection">
|
||||
<div class="hero-badge">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>Help Build an AI That<br>Thinks Like <span>You</span></h1>
|
||||
<p>We're building an AI assistant for MSP engineers. Your expertise shapes how it thinks. Takes about 5 minutes.</p>
|
||||
<div class="hero-stats">
|
||||
<span><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>~5 minutes</span>
|
||||
<span><svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>Confidential</span>
|
||||
<span><svg viewBox="0 0 24 24"><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>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="steps-bar" id="stepsBar"></div>
|
||||
|
||||
<!-- Slides -->
|
||||
<div id="slidesWrap"></div>
|
||||
|
||||
<!-- Completion -->
|
||||
<div class="complete" id="completeScreen">
|
||||
<div class="complete-icon"><svg viewBox="0 0 24 24"><path d="M20 6L9 17l-5-5"/></svg></div>
|
||||
<h2>Done — Thank You!</h2>
|
||||
<p>Your answers will directly shape how FlowPilot troubleshoots. Please send your responses using one of the options below.</p>
|
||||
<div class="complete-actions">
|
||||
<button class="btn btn-accent" onclick="copyAll()">Copy Responses to Clipboard</button>
|
||||
<button class="btn btn-success" onclick="downloadText()">Download as Text</button>
|
||||
</div>
|
||||
<p style="margin-top: 24px; font-size: 12px; color: var(--text-muted);">Paste into an email to <strong style="color: var(--text-secondary);"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="a1ccc8c2c9c0c4cde1d3c4d2cecdd4d5c8cecfc7cdced68fc2cecc">[email protected]</a></strong> or hand off however works best.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script><script>
|
||||
// ══════════════════════════════════════════
|
||||
// ██ SURVEY DATA — 16 questions
|
||||
// ══════════════════════════════════════════
|
||||
const SLIDES = [
|
||||
// ── Slide 0: Pre-Troubleshooting & Preferences ──
|
||||
{
|
||||
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?", hint: null,
|
||||
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" },
|
||||
]
|
||||
},
|
||||
// ── Slide 1: Troubleshooting Philosophy ──
|
||||
{
|
||||
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?", hint: null,
|
||||
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?", hint: null,
|
||||
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"] },
|
||||
]
|
||||
},
|
||||
// ── Slide 2: Scenario ──
|
||||
{
|
||||
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?" },
|
||||
]
|
||||
},
|
||||
// ── Slide 3: Commands & Tools ──
|
||||
{
|
||||
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?",
|
||||
hint: null, 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." },
|
||||
]
|
||||
},
|
||||
// ── Slide 4: Tribal Knowledge ──
|
||||
{
|
||||
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)",
|
||||
hint: null,
|
||||
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"
|
||||
]},
|
||||
]
|
||||
},
|
||||
// ── Slide 5: Domain Ranking ──
|
||||
{
|
||||
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"
|
||||
]},
|
||||
]
|
||||
},
|
||||
// ── Slide 6: FlowPilot Feedback ──
|
||||
{
|
||||
id: "flowpilot",
|
||||
questions: [
|
||||
{ id: "detail_level", type: "mc", num: "15",
|
||||
text: "When an AI suggests a diagnostic step, how specific should it be?",
|
||||
hint: null,
|
||||
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?",
|
||||
hint: null,
|
||||
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"
|
||||
]},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// ██ STATE
|
||||
// ══════════════════════════════════════════
|
||||
let currentSlide = 0;
|
||||
const answers = {};
|
||||
const totalQuestions = SLIDES.reduce((n, s) => n + s.questions.length, 0);
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// ██ RENDER
|
||||
// ══════════════════════════════════════════
|
||||
function init() {
|
||||
// Steps bar
|
||||
const bar = document.getElementById("stepsBar");
|
||||
bar.innerHTML = SLIDES.map((_, i) => `<div class="step-dot" id="step-${i}"></div>`).join("");
|
||||
|
||||
// Slides
|
||||
const wrap = document.getElementById("slidesWrap");
|
||||
wrap.innerHTML = SLIDES.map((slide, si) => {
|
||||
let html = `<div class="slide" id="slide-${si}">`;
|
||||
|
||||
// Scenario box
|
||||
if (slide.scenario) {
|
||||
html += `<div class="scenario-box">
|
||||
<div class="sc-label">${slide.scenario.title}</div>
|
||||
<div class="sc-row"><span class="sc-key">Symptom:</span><span class="sc-val">${slide.scenario.symptom}</span></div>
|
||||
<div class="sc-row"><span class="sc-key">Details:</span><span class="sc-val">${slide.scenario.details}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Questions
|
||||
slide.questions.forEach(q => {
|
||||
html += `<div class="q-card" id="card-${q.id}">`;
|
||||
html += `<div class="q-num">Q${q.num}</div>`;
|
||||
html += `<div class="q-text">${q.text}</div>`;
|
||||
if (q.hint) html += `<div class="q-hint">${q.hint}</div>`;
|
||||
|
||||
if (q.type === "mc") {
|
||||
html += `<div class="mc-options" data-qid="${q.id}" data-type="single">`;
|
||||
q.options.forEach((opt, oi) => {
|
||||
html += `<div class="mc-opt" data-val="${opt}" onclick="selectMC(this, '${q.id}', 'single')"><div class="mc-radio"></div><span>${opt}</span></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
else if (q.type === "mc-multi") {
|
||||
html += `<div class="mc-options" data-qid="${q.id}" data-type="multi">`;
|
||||
q.options.forEach((opt, oi) => {
|
||||
html += `<div class="mc-opt" data-val="${opt}" onclick="selectMC(this, '${q.id}', 'multi')"><div class="mc-check">✓</div><span>${opt}</span></div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
else if (q.type === "range") {
|
||||
html += `<div class="range-wrap">
|
||||
<div class="range-val" id="rv-${q.id}">${q.min}${q.suffix || ''}</div>
|
||||
<input type="range" min="${q.min}" max="${q.max}" step="${q.step}" value="${q.min}" oninput="onRange('${q.id}', this.value, '${q.suffix || ''}')">
|
||||
<div class="range-labels"><span>${q.low_label}</span><span>${q.high_label}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
else if (q.type === "text") {
|
||||
html += `<textarea id="ta-${q.id}" placeholder="Type your answer here..." oninput="onText('${q.id}', this.value)"></textarea>`;
|
||||
}
|
||||
else if (q.type === "rank") {
|
||||
html += `<div class="rank-list" id="rank-${q.id}">`;
|
||||
q.items.forEach((item, ii) => {
|
||||
html += `<div class="rank-item" draggable="true" data-idx="${ii}" data-qid="${q.id}">
|
||||
<div class="rank-grip"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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 class="rank-num">${ii + 1}</div>
|
||||
<div class="rank-label">${item}</div>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`; // close q-card
|
||||
});
|
||||
|
||||
// Nav
|
||||
html += `<div class="nav-btns">`;
|
||||
if (si > 0) html += `<button class="btn btn-ghost" onclick="goSlide(${si - 1})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg> Back</button>`;
|
||||
else html += `<div></div>`;
|
||||
if (si < SLIDES.length - 1) html += `<button class="btn btn-accent" onclick="goSlide(${si + 1})">Next <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg></button>`;
|
||||
else html += `<button class="btn btn-accent" onclick="finish()">Submit <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg></button>`;
|
||||
html += `</div>`;
|
||||
|
||||
html += `</div>`; // close slide
|
||||
return html;
|
||||
}).join("");
|
||||
|
||||
goSlide(0);
|
||||
initDragDrop();
|
||||
}
|
||||
|
||||
function goSlide(idx) {
|
||||
document.querySelectorAll(".slide").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById(`slide-${idx}`).classList.add("active");
|
||||
document.getElementById("completeScreen").classList.remove("active");
|
||||
currentSlide = idx;
|
||||
// Steps
|
||||
SLIDES.forEach((_, i) => {
|
||||
const dot = document.getElementById(`step-${i}`);
|
||||
dot.className = "step-dot" + (i < idx ? " done" : "") + (i === idx ? " active" : "");
|
||||
});
|
||||
// Hero shrinks after first advance
|
||||
document.getElementById("heroSection").style.display = idx === 0 ? "" : "none";
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// ██ INTERACTIONS
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
function selectMC(el, qid, mode) {
|
||||
if (mode === "single") {
|
||||
el.parentElement.querySelectorAll(".mc-opt").forEach(o => o.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
answers[qid] = el.dataset.val;
|
||||
} else {
|
||||
el.classList.toggle("selected");
|
||||
const selected = [...el.parentElement.querySelectorAll(".mc-opt.selected")].map(o => o.dataset.val);
|
||||
answers[qid] = selected;
|
||||
}
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function onRange(qid, val, suffix) {
|
||||
document.getElementById(`rv-${qid}`).textContent = val + suffix;
|
||||
answers[qid] = val + suffix;
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function onText(qid, val) {
|
||||
answers[qid] = val.trim();
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
// Drag and drop ranking
|
||||
function initDragDrop() {
|
||||
document.querySelectorAll(".rank-list").forEach(list => {
|
||||
let dragItem = null;
|
||||
list.querySelectorAll(".rank-item").forEach(item => {
|
||||
item.addEventListener("dragstart", e => {
|
||||
dragItem = item;
|
||||
item.classList.add("dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
item.addEventListener("dragend", () => {
|
||||
item.classList.remove("dragging");
|
||||
list.querySelectorAll(".rank-item").forEach(i => i.classList.remove("drag-over"));
|
||||
// Update numbers and store answer
|
||||
const qid = item.dataset.qid;
|
||||
const items = [...list.querySelectorAll(".rank-item")];
|
||||
items.forEach((it, idx) => it.querySelector(".rank-num").textContent = idx + 1);
|
||||
answers[qid] = items.map(it => it.querySelector(".rank-label").textContent);
|
||||
updateProgress();
|
||||
});
|
||||
item.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
if (item !== dragItem) item.classList.add("drag-over");
|
||||
});
|
||||
item.addEventListener("dragleave", () => item.classList.remove("drag-over"));
|
||||
item.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
item.classList.remove("drag-over");
|
||||
if (item !== dragItem && dragItem) {
|
||||
const allItems = [...list.querySelectorAll(".rank-item")];
|
||||
const fromIdx = allItems.indexOf(dragItem);
|
||||
const toIdx = allItems.indexOf(item);
|
||||
if (fromIdx < toIdx) item.after(dragItem);
|
||||
else item.before(dragItem);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Touch drag support
|
||||
let touchItem = null;
|
||||
let touchClone = null;
|
||||
let touchStartY = 0;
|
||||
|
||||
list.addEventListener("touchstart", e => {
|
||||
const item = e.target.closest(".rank-item");
|
||||
if (!item) return;
|
||||
touchItem = item;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
item.classList.add("dragging");
|
||||
}, { passive: true });
|
||||
|
||||
list.addEventListener("touchmove", e => {
|
||||
if (!touchItem) return;
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const items = [...list.querySelectorAll(".rank-item")];
|
||||
items.forEach(i => i.classList.remove("drag-over"));
|
||||
const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest(".rank-item");
|
||||
if (target && target !== touchItem) target.classList.add("drag-over");
|
||||
}, { passive: false });
|
||||
|
||||
list.addEventListener("touchend", e => {
|
||||
if (!touchItem) return;
|
||||
const touch = e.changedTouches[0];
|
||||
const target = document.elementFromPoint(touch.clientX, touch.clientY)?.closest(".rank-item");
|
||||
if (target && target !== touchItem) {
|
||||
const allItems = [...list.querySelectorAll(".rank-item")];
|
||||
const fromIdx = allItems.indexOf(touchItem);
|
||||
const toIdx = allItems.indexOf(target);
|
||||
if (fromIdx < toIdx) target.after(touchItem);
|
||||
else target.before(touchItem);
|
||||
}
|
||||
touchItem.classList.remove("dragging");
|
||||
list.querySelectorAll(".rank-item").forEach(i => i.classList.remove("drag-over"));
|
||||
const qid = touchItem.dataset.qid;
|
||||
const items = [...list.querySelectorAll(".rank-item")];
|
||||
items.forEach((it, idx) => it.querySelector(".rank-num").textContent = idx + 1);
|
||||
answers[qid] = items.map(it => it.querySelector(".rank-label").textContent);
|
||||
touchItem = null;
|
||||
updateProgress();
|
||||
});
|
||||
});
|
||||
|
||||
// Set initial rank answers
|
||||
document.querySelectorAll(".rank-list").forEach(list => {
|
||||
const items = [...list.querySelectorAll(".rank-item")];
|
||||
if (items.length > 0) {
|
||||
const qid = items[0].dataset.qid;
|
||||
answers[qid] = items.map(it => it.querySelector(".rank-label").textContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// ██ PROGRESS
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
function updateProgress() {
|
||||
let answered = 0;
|
||||
SLIDES.forEach(s => s.questions.forEach(q => {
|
||||
const a = answers[q.id];
|
||||
if (a !== undefined && a !== "" && (!Array.isArray(a) || a.length > 0)) answered++;
|
||||
}));
|
||||
// Rank always counts as answered since it has a default order
|
||||
const pct = Math.round((answered / totalQuestions) * 100);
|
||||
document.getElementById("progressFill").style.width = pct + "%";
|
||||
document.getElementById("progressText").textContent = `${answered} of ${totalQuestions}`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════
|
||||
// ██ FINISH & EXPORT
|
||||
// ══════════════════════════════════════════
|
||||
|
||||
function finish() {
|
||||
document.querySelectorAll(".slide").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById("heroSection").style.display = "none";
|
||||
document.getElementById("stepsBar").style.display = "none";
|
||||
document.getElementById("completeScreen").classList.add("active");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function 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;
|
||||
}
|
||||
|
||||
function copyAll() {
|
||||
navigator.clipboard.writeText(buildOutput()).then(() => toast("Copied! Paste into an email and send.")).catch(() => toast("Copy failed — try download instead."));
|
||||
}
|
||||
|
||||
function downloadText() {
|
||||
const blob = new Blob([buildOutput()], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = "flowpilot-survey-response.txt";
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
t
|
||||
Reference in New Issue
Block a user