Skip to content

Commit 2edebcd

Browse files
authored
feat(feedback): stars rating, optional improvements, optional email (#31)
- 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
1 parent 0152c26 commit 2edebcd

6 files changed

Lines changed: 193 additions & 76 deletions

File tree

frontend/src/api/client.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ describe('API Client', () => {
171171
expect(body.message).toContain('None provided');
172172
});
173173

174+
it('includes optional email in message and payload when provided', async () => {
175+
vi.mocked(global.fetch).mockResolvedValueOnce({
176+
ok: true,
177+
json: () => Promise.resolve({ success: true }),
178+
} as Response);
179+
180+
await submitFeedback({
181+
rating: 4,
182+
improvements: ['Documentation'],
183+
email: 'user@example.com',
184+
});
185+
186+
const fetchCall = vi.mocked(global.fetch).mock.calls[0];
187+
const body = JSON.parse(fetchCall[1]?.body as string);
188+
expect(body.message).toContain('Email: user@example.com');
189+
expect(body.email).toBe('user@example.com');
190+
});
191+
174192
it('returns success even on Web3Forms error', async () => {
175193
vi.mocked(global.fetch).mockResolvedValueOnce({
176194
ok: true,

frontend/src/api/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ export async function submitFeedback(request: FeedbackRequest): Promise<Feedback
164164
165165
Areas for Improvement: ${improvementsText}
166166
167-
Additional Feedback: ${request.additionalFeedback || 'None provided'}`;
167+
Additional Feedback: ${request.additionalFeedback || 'None provided'}
168+
169+
Email: ${request.email || 'Not provided'}`;
168170

169171
try {
170172
const response = await fetch(WEB3FORMS_URL, {
@@ -177,6 +179,7 @@ Additional Feedback: ${request.additionalFeedback || 'None provided'}`;
177179
subject: `Rosetta Feedback - Rating ${request.rating}/5 (${ratingLabel})`,
178180
from_name: 'Rosetta Feedback',
179181
message,
182+
...(request.email && { email: request.email }),
180183
}),
181184
});
182185

frontend/src/components/features/feedback/Feedback.css

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -109,50 +109,44 @@
109109
color: var(--color-neutral-400);
110110
}
111111

112-
/* Rating emojis */
113-
.feedback-rating {
112+
/* Star rating */
113+
.feedback-stars {
114114
display: flex;
115115
justify-content: center;
116-
gap: 0.75rem;
116+
align-items: center;
117+
gap: 0.5rem;
117118
margin-top: 1.5rem;
118119
}
119120

120-
.feedback-emoji {
121+
.feedback-star {
121122
display: flex;
122123
align-items: center;
123124
justify-content: center;
124-
width: 3.5rem;
125-
height: 3.5rem;
126-
background: var(--color-neutral-100);
127-
border: 2px solid transparent;
128-
border-radius: 50%;
125+
padding: 0.25rem;
126+
background: transparent;
127+
border: none;
128+
border-radius: 0.5rem;
129129
cursor: pointer;
130-
transition: all 0.2s;
130+
color: #ca8a04;
131+
transition: color 0.2s, transform 0.2s;
131132
}
132133

133-
:root.dark .feedback-emoji {
134-
background: var(--color-neutral-700);
134+
:root.dark .feedback-star {
135+
color: #a16207;
135136
}
136137

137-
.feedback-emoji:hover {
138-
transform: scale(1.15);
139-
border-color: var(--color-accent-300);
138+
.feedback-star:hover {
139+
color: #eab308;
140+
transform: scale(1.1);
140141
}
141142

142-
.feedback-emoji.selected {
143-
border-color: var(--color-accent-500);
144-
background: var(--color-accent-100);
145-
transform: scale(1.2);
146-
}
147-
148-
:root.dark .feedback-emoji.selected {
149-
background: rgba(16, 185, 129, 0.2);
150-
border-color: var(--color-accent-400);
143+
.feedback-star.filled {
144+
color: #eab308;
151145
}
152146

153-
.feedback-emoji-icon {
154-
font-size: 1.75rem;
155-
line-height: 1;
147+
:root.dark .feedback-star:hover,
148+
:root.dark .feedback-star.filled {
149+
color: #facc15;
156150
}
157151

158152
/* Improvement chips */
@@ -185,6 +179,12 @@
185179
border-color: var(--color-accent-300);
186180
}
187181

182+
.feedback-chip:focus {
183+
outline: none;
184+
border-color: #10b981;
185+
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25);
186+
}
187+
188188
.feedback-chip.selected {
189189
background: var(--color-accent-100);
190190
border-color: var(--color-accent-500);
@@ -232,6 +232,58 @@
232232
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
233233
}
234234

235+
/* Optional email field */
236+
.feedback-email-label {
237+
display: block;
238+
font-size: 0.875rem;
239+
font-weight: 500;
240+
color: var(--color-neutral-700);
241+
margin-bottom: 0.25rem;
242+
}
243+
244+
:root.dark .feedback-email-label {
245+
color: var(--color-neutral-300);
246+
}
247+
248+
.feedback-email-hint {
249+
margin-bottom: 0.5rem;
250+
text-align: left;
251+
font-size: 0.8125rem;
252+
}
253+
254+
.feedback-email {
255+
width: 100%;
256+
padding: 0.75rem 1rem;
257+
background: var(--color-neutral-100);
258+
border: 1px solid #10b981;
259+
border-radius: 0.75rem;
260+
font-size: 0.9375rem;
261+
font-family: inherit;
262+
color: var(--color-neutral-900);
263+
margin-bottom: 1.5rem;
264+
transition: border-color 0.15s, box-shadow 0.15s;
265+
}
266+
267+
:root.dark .feedback-email {
268+
background: rgba(15, 23, 42, 0.5);
269+
border: 1px solid #10b981;
270+
color: white;
271+
}
272+
273+
.feedback-email::placeholder {
274+
color: var(--color-neutral-400);
275+
}
276+
277+
:root.dark .feedback-email::placeholder {
278+
color: var(--color-neutral-500);
279+
}
280+
281+
.feedback-email:focus {
282+
outline: none;
283+
border-color: var(--color-accent-500);
284+
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
285+
}
286+
235287
/* Navigation */
236288
.feedback-nav {
237289
display: flex;

frontend/src/components/features/feedback/FeedbackModal.test.tsx

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2-
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2+
import { render, screen, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import { FeedbackModal } from './FeedbackModal';
55

@@ -25,25 +25,25 @@ describe('FeedbackModal', () => {
2525
expect(screen.queryByText('How satisfied are you with Rosetta?')).not.toBeInTheDocument();
2626
});
2727

28-
it('shows rating emojis on step 1', () => {
28+
it('shows star rating on step 1', () => {
2929
render(<FeedbackModal isOpen={true} onClose={() => {}} />);
3030

31-
// Check for emoji buttons (5 ratings)
32-
const emojiButtons = screen.getAllByRole('button').filter(
33-
btn => btn.classList.contains('feedback-emoji')
31+
// Check for star buttons (5 stars)
32+
const starButtons = screen.getAllByRole('button').filter(
33+
btn => btn.classList.contains('feedback-star')
3434
);
35-
expect(emojiButtons).toHaveLength(5);
35+
expect(starButtons).toHaveLength(5);
3636
});
3737

3838
it('advances to step 2 when rating is selected', async () => {
3939
const user = userEvent.setup();
4040
render(<FeedbackModal isOpen={true} onClose={() => {}} />);
4141

42-
// Click the "Very Satisfied" emoji (last one)
43-
const emojiButtons = screen.getAllByRole('button').filter(
44-
btn => btn.classList.contains('feedback-emoji')
42+
// Click the 5th star (5 out of 5)
43+
const starButtons = screen.getAllByRole('button').filter(
44+
btn => btn.classList.contains('feedback-star')
4545
);
46-
await user.click(emojiButtons[4]);
46+
await user.click(starButtons[4]);
4747

4848
// Wait for step 2 to appear
4949
await waitFor(() => {
@@ -55,11 +55,11 @@ describe('FeedbackModal', () => {
5555
const user = userEvent.setup();
5656
render(<FeedbackModal isOpen={true} onClose={() => {}} />);
5757

58-
// Select a rating to advance
59-
const emojiButtons = screen.getAllByRole('button').filter(
60-
btn => btn.classList.contains('feedback-emoji')
58+
// Select a rating to advance (3rd star)
59+
const starButtons = screen.getAllByRole('button').filter(
60+
btn => btn.classList.contains('feedback-star')
6161
);
62-
await user.click(emojiButtons[2]);
62+
await user.click(starButtons[2]);
6363

6464
await waitFor(() => {
6565
expect(screen.getByText('Translation quality')).toBeInTheDocument();
@@ -72,11 +72,11 @@ describe('FeedbackModal', () => {
7272
const user = userEvent.setup();
7373
render(<FeedbackModal isOpen={true} onClose={() => {}} />);
7474

75-
// Select rating
76-
const emojiButtons = screen.getAllByRole('button').filter(
77-
btn => btn.classList.contains('feedback-emoji')
75+
// Select rating (4th star)
76+
const starButtons = screen.getAllByRole('button').filter(
77+
btn => btn.classList.contains('feedback-star')
7878
);
79-
await user.click(emojiButtons[3]);
79+
await user.click(starButtons[3]);
8080

8181
await waitFor(() => {
8282
expect(screen.getByText('What could we improve?')).toBeInTheDocument();
@@ -96,8 +96,9 @@ describe('FeedbackModal', () => {
9696
const user = userEvent.setup();
9797
render(<FeedbackModal isOpen={true} onClose={onClose} />);
9898

99-
const closeButton = screen.getByRole('button', { name: '' }); // X button has no text
100-
await user.click(closeButton);
99+
const closeButton = document.querySelector('.feedback-close');
100+
expect(closeButton).toBeInTheDocument();
101+
await user.click(closeButton!);
101102

102103
expect(onClose).toHaveBeenCalled();
103104
});
@@ -109,11 +110,11 @@ describe('FeedbackModal', () => {
109110

110111
render(<FeedbackModal isOpen={true} onClose={onClose} />);
111112

112-
// Step 1: Select rating
113-
const emojiButtons = screen.getAllByRole('button').filter(
114-
btn => btn.classList.contains('feedback-emoji')
113+
// Step 1: Select rating (5th star)
114+
const starButtons = screen.getAllByRole('button').filter(
115+
btn => btn.classList.contains('feedback-star')
115116
);
116-
await user.click(emojiButtons[4]);
117+
await user.click(starButtons[4]);
117118

118119
// Step 2: Select improvements
119120
await waitFor(() => {
@@ -137,21 +138,46 @@ describe('FeedbackModal', () => {
137138
rating: 5,
138139
improvements: ['Translation quality'],
139140
additionalFeedback: 'Great tool!',
141+
email: undefined,
140142
});
141143
});
142144
});
143145

146+
it('submits feedback with optional email when provided', async () => {
147+
const user = userEvent.setup();
148+
vi.mocked(submitFeedback).mockResolvedValueOnce({ success: true });
149+
150+
render(<FeedbackModal isOpen={true} onClose={() => {}} />);
151+
152+
const starButtons = screen.getAllByRole('button').filter(
153+
btn => btn.classList.contains('feedback-star')
154+
);
155+
await user.click(starButtons[4]);
156+
await waitFor(() => screen.getByText('What could we improve?'));
157+
await user.click(screen.getByText('Next'));
158+
await waitFor(() => screen.getByText('Any additional feedback?'));
159+
const emailInput = screen.getByLabelText(/email \(optional\)/i);
160+
await user.type(emailInput, 'user@example.com');
161+
await user.click(screen.getByText('Submit'));
162+
163+
await waitFor(() => {
164+
expect(submitFeedback).toHaveBeenCalledWith(
165+
expect.objectContaining({ email: 'user@example.com' })
166+
);
167+
});
168+
});
169+
144170
it('shows success message after submission', async () => {
145171
const user = userEvent.setup();
146172
vi.mocked(submitFeedback).mockResolvedValueOnce({ success: true });
147173

148174
render(<FeedbackModal isOpen={true} onClose={() => {}} />);
149175

150176
// Complete the flow
151-
const emojiButtons = screen.getAllByRole('button').filter(
152-
btn => btn.classList.contains('feedback-emoji')
177+
const starButtons = screen.getAllByRole('button').filter(
178+
btn => btn.classList.contains('feedback-star')
153179
);
154-
await user.click(emojiButtons[4]);
180+
await user.click(starButtons[4]);
155181

156182
await waitFor(() => screen.getByText('What could we improve?'));
157183
await user.click(screen.getByText('Speed/Performance'));
@@ -169,11 +195,11 @@ describe('FeedbackModal', () => {
169195
const user = userEvent.setup();
170196
render(<FeedbackModal isOpen={true} onClose={() => {}} />);
171197

172-
// Go to step 2
173-
const emojiButtons = screen.getAllByRole('button').filter(
174-
btn => btn.classList.contains('feedback-emoji')
198+
// Go to step 2 (3rd star)
199+
const starButtons = screen.getAllByRole('button').filter(
200+
btn => btn.classList.contains('feedback-star')
175201
);
176-
await user.click(emojiButtons[2]);
202+
await user.click(starButtons[2]);
177203

178204
await waitFor(() => screen.getByText('What could we improve?'));
179205
await user.click(screen.getByText('User interface'));

0 commit comments

Comments
 (0)