From 2b583ad8718c19a439a675536a269a33f82de114 Mon Sep 17 00:00:00 2001 From: ewalid Date: Mon, 2 Mar 2026 17:12:16 +0100 Subject: [PATCH 1/3] feat(feedback): stars rating, optional improvements, optional email - Replace smiley rating with 1-5 star rating (yellow in light/dark mode) - Allow skipping step 2 (improvements optional; multi-select unchanged) - Add optional email field after additional feedback for follow-up - Improvement chips: mint green focus ring - Types: FeedbackRequest.email; API: message + Web3Forms email payload - Tests updated for stars and email; all 23 tests pass Made-with: Cursor --- frontend/src/api/client.test.ts | 18 +++ frontend/src/api/client.ts | 5 +- .../components/features/feedback/Feedback.css | 106 +++++++++++++----- .../features/feedback/FeedbackModal.test.tsx | 88 ++++++++++----- .../features/feedback/FeedbackModal.tsx | 51 ++++++--- frontend/src/types/index.ts | 1 + 6 files changed, 193 insertions(+), 76 deletions(-) diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index f0918fe..b5aa6cc 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -171,6 +171,24 @@ describe('API Client', () => { expect(body.message).toContain('None provided'); }); + it('includes optional email in message and payload when provided', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + } as Response); + + await submitFeedback({ + rating: 4, + improvements: ['Documentation'], + email: 'user@example.com', + }); + + const fetchCall = vi.mocked(global.fetch).mock.calls[0]; + const body = JSON.parse(fetchCall[1]?.body as string); + expect(body.message).toContain('Email: user@example.com'); + expect(body.email).toBe('user@example.com'); + }); + it('returns success even on Web3Forms error', async () => { vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8cb50a8..4ae4518 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -164,7 +164,9 @@ export async function submitFeedback(request: FeedbackRequest): Promise { expect(screen.queryByText('How satisfied are you with Rosetta?')).not.toBeInTheDocument(); }); - it('shows rating emojis on step 1', () => { + it('shows star rating on step 1', () => { render( {}} />); - // Check for emoji buttons (5 ratings) - const emojiButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('feedback-emoji') + // Check for star buttons (5 stars) + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') ); - expect(emojiButtons).toHaveLength(5); + expect(starButtons).toHaveLength(5); }); it('advances to step 2 when rating is selected', async () => { const user = userEvent.setup(); render( {}} />); - // Click the "Very Satisfied" emoji (last one) - const emojiButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('feedback-emoji') + // Click the 5th star (5 out of 5) + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') ); - await user.click(emojiButtons[4]); + await user.click(starButtons[4]); // Wait for step 2 to appear await waitFor(() => { @@ -55,11 +55,11 @@ describe('FeedbackModal', () => { const user = userEvent.setup(); render( {}} />); - // Select a rating to advance - const emojiButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('feedback-emoji') + // Select a rating to advance (3rd star) + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') ); - await user.click(emojiButtons[2]); + await user.click(starButtons[2]); await waitFor(() => { expect(screen.getByText('Translation quality')).toBeInTheDocument(); @@ -72,11 +72,11 @@ describe('FeedbackModal', () => { const user = userEvent.setup(); render( {}} />); - // Select rating - const emojiButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('feedback-emoji') + // Select rating (4th star) + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') ); - await user.click(emojiButtons[3]); + await user.click(starButtons[3]); await waitFor(() => { expect(screen.getByText('What could we improve?')).toBeInTheDocument(); @@ -96,8 +96,9 @@ describe('FeedbackModal', () => { const user = userEvent.setup(); render(); - const closeButton = screen.getByRole('button', { name: '' }); // X button has no text - await user.click(closeButton); + const closeButton = document.querySelector('.feedback-close'); + expect(closeButton).toBeInTheDocument(); + await user.click(closeButton!); expect(onClose).toHaveBeenCalled(); }); @@ -109,11 +110,11 @@ describe('FeedbackModal', () => { render(); - // Step 1: Select rating - const emojiButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('feedback-emoji') + // Step 1: Select rating (5th star) + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') ); - await user.click(emojiButtons[4]); + await user.click(starButtons[4]); // Step 2: Select improvements await waitFor(() => { @@ -137,10 +138,35 @@ describe('FeedbackModal', () => { rating: 5, improvements: ['Translation quality'], additionalFeedback: 'Great tool!', + email: undefined, }); }); }); + it('submits feedback with optional email when provided', async () => { + const user = userEvent.setup(); + vi.mocked(submitFeedback).mockResolvedValueOnce({ success: true }); + + render( {}} />); + + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') + ); + await user.click(starButtons[4]); + await waitFor(() => screen.getByText('What could we improve?')); + await user.click(screen.getByText('Next')); + await waitFor(() => screen.getByText('Any additional feedback?')); + const emailInput = screen.getByLabelText(/email \(optional\)/i); + await user.type(emailInput, 'user@example.com'); + await user.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ email: 'user@example.com' }) + ); + }); + }); + it('shows success message after submission', async () => { const user = userEvent.setup(); vi.mocked(submitFeedback).mockResolvedValueOnce({ success: true }); @@ -148,10 +174,10 @@ describe('FeedbackModal', () => { render( {}} />); // Complete the flow - const emojiButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('feedback-emoji') + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') ); - await user.click(emojiButtons[4]); + await user.click(starButtons[4]); await waitFor(() => screen.getByText('What could we improve?')); await user.click(screen.getByText('Speed/Performance')); @@ -169,11 +195,11 @@ describe('FeedbackModal', () => { const user = userEvent.setup(); render( {}} />); - // Go to step 2 - const emojiButtons = screen.getAllByRole('button').filter( - btn => btn.classList.contains('feedback-emoji') + // Go to step 2 (3rd star) + const starButtons = screen.getAllByRole('button').filter( + btn => btn.classList.contains('feedback-star') ); - await user.click(emojiButtons[2]); + await user.click(starButtons[2]); await waitFor(() => screen.getByText('What could we improve?')); await user.click(screen.getByText('User interface')); diff --git a/frontend/src/components/features/feedback/FeedbackModal.tsx b/frontend/src/components/features/feedback/FeedbackModal.tsx index 94ace40..ede8561 100644 --- a/frontend/src/components/features/feedback/FeedbackModal.tsx +++ b/frontend/src/components/features/feedback/FeedbackModal.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Send, ChevronRight, ChevronLeft, CheckCircle } from 'lucide-react'; +import { X, Send, ChevronRight, ChevronLeft, CheckCircle, Star } from 'lucide-react'; import { Button } from '../../ui'; import { submitFeedback } from '../../../api/client'; import './Feedback.css'; @@ -12,14 +12,6 @@ interface FeedbackModalProps { type Rating = 1 | 2 | 3 | 4 | 5 | null; -const ratingEmojis = [ - { value: 1 as const, emoji: '😞', label: 'Very Dissatisfied' }, - { value: 2 as const, emoji: '😕', label: 'Dissatisfied' }, - { value: 3 as const, emoji: '😐', label: 'Neutral' }, - { value: 4 as const, emoji: '😊', label: 'Satisfied' }, - { value: 5 as const, emoji: '😍', label: 'Very Satisfied' }, -]; - const improvements = [ 'Translation quality', 'Speed/Performance', @@ -34,6 +26,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) { const [rating, setRating] = useState(null); const [selectedImprovements, setSelectedImprovements] = useState([]); const [additionalFeedback, setAdditionalFeedback] = useState(''); + const [email, setEmail] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); @@ -42,6 +35,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) { setRating(null); setSelectedImprovements([]); setAdditionalFeedback(''); + setEmail(''); setIsSubmitting(false); setIsSubmitted(false); }, []); @@ -74,6 +68,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) { rating: rating!, improvements: selectedImprovements, additionalFeedback: additionalFeedback || undefined, + email: email.trim() || undefined, }); setIsSubmitted(true); // Auto-close after success @@ -85,9 +80,9 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) { } finally { setIsSubmitting(false); } - }, [rating, selectedImprovements, additionalFeedback, handleClose]); + }, [rating, selectedImprovements, additionalFeedback, email, handleClose]); - const canProceedToStep3 = selectedImprovements.length > 0; + const canProceedToStep3 = true; return ( @@ -160,15 +155,22 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {

How satisfied are you with Rosetta?

-
- {ratingEmojis.map(({ value, emoji, label }) => ( +
+ {[1, 2, 3, 4, 5].map(value => ( ))}
@@ -239,6 +241,21 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) { placeholder="Tell us more about your experience..." rows={4} /> + +

+ So we can get back to you about your feedback. +

+ setEmail(e.target.value)} + autoComplete="email" + />