From 73c529d6f317228d262606fbc59fdd33bbb3dd0f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 23 Mar 2026 13:12:06 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20beta=20feedback=20widget=20=E2=80=94=20?= =?UTF-8?q?frictionless=20in-session=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack beta feedback system: Backend: - BetaFeedback model with reaction, category, text, page context - POST /feedback/beta (any auth user), GET /feedback/beta (admin, filtered) - Alembic migration 065 with indexes on user_id, reaction, created_at Frontend: - Persistent "Feedback" tab on right edge of all authenticated pages - Slide-out panel: quick reaction (👍😐👎), category pills, optional text - Auto-captures page URL and FlowPilot session ID - Hidden on mobile (<640px), closes on Escape/outside click - Shows "Thanks!" confirmation then auto-closes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../versions/065_add_beta_feedback_table.py | 43 +++ backend/app/api/endpoints/beta_feedback.py | 59 ++++ backend/app/api/router.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/beta_feedback.py | 20 ++ backend/app/schemas/beta_feedback.py | 40 +++ frontend/src/api/betaFeedback.ts | 6 + frontend/src/api/index.ts | 1 + .../src/components/common/FeedbackWidget.tsx | 290 ++++++++++++++++++ frontend/src/components/layout/AppLayout.tsx | 4 + 10 files changed, 467 insertions(+) create mode 100644 backend/alembic/versions/065_add_beta_feedback_table.py create mode 100644 backend/app/api/endpoints/beta_feedback.py create mode 100644 backend/app/models/beta_feedback.py create mode 100644 backend/app/schemas/beta_feedback.py create mode 100644 frontend/src/api/betaFeedback.ts create mode 100644 frontend/src/components/common/FeedbackWidget.tsx diff --git a/backend/alembic/versions/065_add_beta_feedback_table.py b/backend/alembic/versions/065_add_beta_feedback_table.py new file mode 100644 index 00000000..0c3bc9c2 --- /dev/null +++ b/backend/alembic/versions/065_add_beta_feedback_table.py @@ -0,0 +1,43 @@ +"""add beta_feedback table + +Revision ID: 065 +Revises: 064 +Create Date: 2026-03-23 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = "065" +down_revision: Union[str, None] = "064" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "beta_feedback", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("reaction", sa.String(10), nullable=False), + sa.Column("category", sa.String(30), nullable=True), + sa.Column("text", sa.Text, nullable=True), + sa.Column("page_url", sa.String(500), nullable=True), + sa.Column("session_id", sa.String(100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_beta_feedback_user_id", "beta_feedback", ["user_id"]) + op.create_index("ix_beta_feedback_reaction", "beta_feedback", ["reaction"]) + op.create_index("ix_beta_feedback_created_at", "beta_feedback", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_beta_feedback_created_at", table_name="beta_feedback") + op.drop_index("ix_beta_feedback_reaction", table_name="beta_feedback") + op.drop_index("ix_beta_feedback_user_id", table_name="beta_feedback") + op.drop_table("beta_feedback") diff --git a/backend/app/api/endpoints/beta_feedback.py b/backend/app/api/endpoints/beta_feedback.py new file mode 100644 index 00000000..8e5774d3 --- /dev/null +++ b/backend/app/api/endpoints/beta_feedback.py @@ -0,0 +1,59 @@ +import logging +from typing import Annotated, Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from app.api.deps import get_current_active_user, require_admin +from app.core.database import get_db +from app.models.user import User +from app.models.beta_feedback import BetaFeedback +from app.schemas.beta_feedback import BetaFeedbackCreate, BetaFeedbackResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["beta-feedback"]) + + +@router.post("/feedback/beta", response_model=BetaFeedbackResponse, status_code=201) +async def submit_beta_feedback( + data: BetaFeedbackCreate, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Submit beta feedback. Any authenticated user can submit.""" + record = BetaFeedback( + user_id=current_user.id, + reaction=data.reaction.value, + category=data.category.value if data.category else None, + text=data.text, + page_url=data.page_url, + session_id=data.session_id, + ) + db.add(record) + await db.commit() + await db.refresh(record) + return record + + +@router.get("/feedback/beta", response_model=list[BetaFeedbackResponse]) +async def list_beta_feedback( + current_user: Annotated[User, Depends(require_admin)], + db: Annotated[AsyncSession, Depends(get_db)], + reaction: Optional[str] = Query(None, description="Filter by reaction: positive, neutral, negative"), + category: Optional[str] = Query(None, description="Filter by category: bug, feature, confusing, praise"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), +): + """List all beta feedback. Super admin only.""" + query = select(BetaFeedback) + + if reaction: + query = query.where(BetaFeedback.reaction == reaction) + if category: + query = query.where(BetaFeedback.category == category) + + query = query.order_by(BetaFeedback.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(query) + return result.scalars().all() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 01465810..d98042b7 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -29,6 +29,7 @@ from app.api.endpoints import public_templates from app.api.endpoints import admin_gallery from app.api.endpoints import uploads from app.api.endpoints import script_builder +from app.api.endpoints import beta_feedback api_router = APIRouter() @@ -83,3 +84,4 @@ api_router.include_router(public_templates.router) api_router.include_router(admin_gallery.router) api_router.include_router(uploads.router) api_router.include_router(script_builder.router) +api_router.include_router(beta_feedback.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a38cc391..07db1ba8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -49,6 +49,7 @@ from .notification import Notification from .psa_activity_log import PsaActivityLog from .file_upload import FileUpload from .ai_session_embedding import AISessionEmbedding +from .beta_feedback import BetaFeedback __all__ = [ "User", @@ -112,4 +113,5 @@ __all__ = [ "PsaActivityLog", "FileUpload", "AISessionEmbedding", + "BetaFeedback", ] diff --git a/backend/app/models/beta_feedback.py b/backend/app/models/beta_feedback.py new file mode 100644 index 00000000..bb103c90 --- /dev/null +++ b/backend/app/models/beta_feedback.py @@ -0,0 +1,20 @@ +import uuid +from datetime import datetime, timezone +from typing import Optional +from sqlalchemy import String, Text, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID +from app.core.database import Base + + +class BetaFeedback(Base): + __tablename__ = "beta_feedback" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + reaction: Mapped[str] = mapped_column(String(10), nullable=False) # 'positive', 'neutral', 'negative' + category: Mapped[Optional[str]] = mapped_column(String(30), nullable=True) # 'bug', 'feature', 'confusing', 'praise' + text: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + page_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + session_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # FlowPilot session ID if applicable + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/schemas/beta_feedback.py b/backend/app/schemas/beta_feedback.py new file mode 100644 index 00000000..b816571c --- /dev/null +++ b/backend/app/schemas/beta_feedback.py @@ -0,0 +1,40 @@ +import uuid +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class ReactionType(str, Enum): + POSITIVE = "positive" + NEUTRAL = "neutral" + NEGATIVE = "negative" + + +class FeedbackCategory(str, Enum): + BUG = "bug" + FEATURE = "feature" + CONFUSING = "confusing" + PRAISE = "praise" + + +class BetaFeedbackCreate(BaseModel): + reaction: ReactionType + category: Optional[FeedbackCategory] = None + text: Optional[str] = Field(None, max_length=5000) + page_url: Optional[str] = Field(None, max_length=500) + session_id: Optional[str] = Field(None, max_length=100) + + +class BetaFeedbackResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + reaction: str + category: Optional[str] = None + text: Optional[str] = None + page_url: Optional[str] = None + session_id: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/frontend/src/api/betaFeedback.ts b/frontend/src/api/betaFeedback.ts new file mode 100644 index 00000000..88a2fe49 --- /dev/null +++ b/frontend/src/api/betaFeedback.ts @@ -0,0 +1,6 @@ +import { apiClient } from './client' + +export const betaFeedbackApi = { + submit: (data: { reaction: string; category?: string; text?: string; page_url?: string; session_id?: string }) => + apiClient.post('/feedback/beta', data).then(r => r.data), +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index bcbb2656..904792dd 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -31,3 +31,4 @@ export { notificationsApi } from './notifications' export { publicTemplatesApi } from './publicTemplates' export { uploadsApi, default as uploadsApiDefault } from './uploads' export { scriptBuilderApi } from './scriptBuilder' +export { betaFeedbackApi } from './betaFeedback' diff --git a/frontend/src/components/common/FeedbackWidget.tsx b/frontend/src/components/common/FeedbackWidget.tsx new file mode 100644 index 00000000..735cc4aa --- /dev/null +++ b/frontend/src/components/common/FeedbackWidget.tsx @@ -0,0 +1,290 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { MessageSquare, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +import { betaFeedbackApi } from '@/api/betaFeedback' + +const REACTIONS = [ + { value: 'positive', emoji: '👍', label: 'Good' }, + { value: 'neutral', emoji: '😐', label: 'Okay' }, + { value: 'negative', emoji: '👎', label: 'Bad' }, +] as const + +const CATEGORIES = ['Bug', 'Feature Idea', 'Confusing', 'Praise'] as const + +type Reaction = (typeof REACTIONS)[number]['value'] +type Category = (typeof CATEGORIES)[number] + +export function FeedbackWidget() { + const [open, setOpen] = useState(false) + const [reaction, setReaction] = useState(null) + const [category, setCategory] = useState(null) + const [text, setText] = useState('') + const [submitting, setSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + const panelRef = useRef(null) + + const resetForm = useCallback(() => { + setReaction(null) + setCategory(null) + setText('') + setSubmitted(false) + }, []) + + const closePanel = useCallback(() => { + setOpen(false) + // Reset form after close animation + setTimeout(resetForm, 200) + }, [resetForm]) + + // Close on Escape + useEffect(() => { + if (!open) return + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closePanel() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [open, closePanel]) + + // Close on click outside + useEffect(() => { + if (!open) return + const handleClick = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + closePanel() + } + } + // Delay to avoid the opening click triggering immediate close + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClick) + }, 0) + return () => { + clearTimeout(timer) + document.removeEventListener('mousedown', handleClick) + } + }, [open, closePanel]) + + const handleSubmit = async () => { + if (!reaction) return + setSubmitting(true) + + const pageUrl = window.location.pathname + // Extract session ID if on a FlowPilot page + let sessionId: string | undefined + if (pageUrl.includes('/pilot')) { + const match = pageUrl.match(/\/pilot\/([^/]+)/) + if (match) sessionId = match[1] + } + + try { + await betaFeedbackApi.submit({ + reaction, + category: category ?? undefined, + text: text.trim() || undefined, + page_url: pageUrl, + session_id: sessionId, + }) + setSubmitted(true) + setTimeout(closePanel, 1200) + } catch { + toast.error('Failed to send feedback') + } finally { + setSubmitting(false) + } + } + + return ( + <> + {/* Floating tab - hidden on mobile (<640px) */} + + + {/* Slide-out panel */} +
+ {/* Header */} +
+

+ How's it going? +

+ +
+ + {submitted ? ( + /* Thank you state */ +
+

+ Thanks! +

+
+ ) : ( + /* Form */ +
+ {/* Quick reactions */} +
+

+ Quick reaction +

+
+ {REACTIONS.map((r) => ( + + ))} +
+
+ + {/* Category pills */} +
+

+ Category (optional) +

+
+ {CATEGORIES.map((c) => ( + + ))} +
+
+ + {/* Text area */} +
+

+ Details (optional) +

+