Skip to content

Commit acdf319

Browse files
authored
feat(feedback): in‑app feedback with delegated SMTP OAuth; navbar icon order; seed + test scripts (#7)
* Feedback: modal + delegated SMTP OAuth email (seedable token cache) + navbar icon order * changing navbar order for GH button and feedback button * readding changelog script * adding more custimizability to test script * securing email API; making sent emails look polished and prettyyy; polishing feedback sent modal * fix(feedback): avoid boot crash when env unset * guarding msal client creation when SMTP env vars are absent (dev QoL)
1 parent 4023e82 commit acdf319

11 files changed

Lines changed: 868 additions & 8 deletions

File tree

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ DB_HOST=localhost
1212
DB_PORT=5432
1313
DB_NAME=tigertype
1414

15+
## Feedback Email
16+
FEEDBACK_EMAIL_FROM=cs-tigertype@princeton.edu
17+
FEEDBACK_EMAIL_TO_TEAM=cs-tigertype@princeton.edu,it.admin@tigerapps.org
18+
FEEDBACK_REPLY_TO=cs-tigertype@princeton.edu,it.admin@tigerapps.org
19+
SITE_URL=https://tigertype.tigerapps.org
20+
1521
# Scraping Configuration
1622
OPENAI_API_KEY=your_api_key_here
1723
PRINCETON_API_KEY=""
@@ -43,3 +49,11 @@ CHANGELOG_PUBLISH_TOKEN=your_shared_secret_token
4349

4450
# Start PostgreSQL service
4551
# brew services start postgresql
52+
53+
# Delegated SMTP OAuth (device code, cached)
54+
# Run: node server/scripts/seed_smtp_oauth_device_login.js (saves a cache file and prints JSON)
55+
# For Heroku, set SMTP_OAUTH_CACHE to the printed JSON
56+
AZURE_TENANT_ID=
57+
AZURE_CLIENT_ID=
58+
SMTP_SENDER=cs-tigertype@princeton.edu
59+
SMTP_OAUTH_CACHE= (optional; JSON blob of MSAL cache for headless servers)

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ AGENTS.*
1414
# Environment and Configuration
1515
.env
1616
.venv
17+
server/.smtp_oauth_cache.json
1718

1819
# Dependencies
1920
node_modules
@@ -34,4 +35,4 @@ __pycache__
3435
.gitattributes
3536

3637
# Miscellaneous
37-
.bak
38+
.bak
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
.feedback-form {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 1.25rem;
5+
}
6+
7+
.feedback-form label {
8+
display: flex;
9+
flex-direction: column;
10+
gap: 0.5rem;
11+
font-size: 0.95rem;
12+
color: var(--mode-text-color, #f5f5f5);
13+
}
14+
15+
.feedback-form select,
16+
.feedback-form textarea,
17+
.feedback-form input {
18+
width: 100%;
19+
padding: 0.75rem;
20+
border-radius: 8px;
21+
border: 1px solid rgba(245, 128, 37, 0.25);
22+
background: var(--input-bg, rgba(18, 18, 18, 0.85));
23+
color: var(--mode-text-color, #f5f5f5);
24+
font-family: inherit;
25+
font-size: 0.95rem;
26+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
27+
}
28+
29+
.feedback-form select:focus,
30+
.feedback-form textarea:focus,
31+
.feedback-form input:focus {
32+
outline: none;
33+
border-color: #F58025;
34+
box-shadow: 0 0 0 2px rgba(245, 128, 37, 0.2);
35+
}
36+
37+
.feedback-form textarea {
38+
min-height: 160px;
39+
resize: vertical;
40+
}
41+
42+
.feedback-hint {
43+
font-size: 0.8rem;
44+
color: var(--subtle-text-color, rgba(255, 255, 255, 0.6));
45+
align-self: flex-end;
46+
}
47+
48+
.feedback-actions {
49+
display: flex;
50+
justify-content: flex-end;
51+
gap: 0.75rem;
52+
}
53+
54+
.feedback-primary-button,
55+
.feedback-secondary-button {
56+
padding: 0.65rem 1.4rem;
57+
border-radius: 6px;
58+
font-weight: 600;
59+
cursor: pointer;
60+
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
61+
border: none;
62+
}
63+
64+
.feedback-primary-button {
65+
background-color: #F58025;
66+
color: #121212;
67+
box-shadow: 0 2px 8px rgba(245, 128, 37, 0.35);
68+
}
69+
70+
.feedback-primary-button:hover:not(:disabled) {
71+
transform: translateY(-1px);
72+
box-shadow: 0 4px 10px rgba(245, 128, 37, 0.4);
73+
}
74+
75+
.feedback-secondary-button {
76+
background: transparent;
77+
border: 1px solid rgba(245, 128, 37, 0.5);
78+
color: var(--mode-text-color, #f5f5f5);
79+
}
80+
81+
.feedback-secondary-button:hover:not(:disabled) {
82+
transform: translateY(-1px);
83+
border-color: #F58025;
84+
}
85+
86+
.feedback-primary-button:disabled,
87+
.feedback-secondary-button:disabled {
88+
opacity: 0.6;
89+
cursor: not-allowed;
90+
transform: none;
91+
box-shadow: none;
92+
}
93+
94+
.feedback-error {
95+
margin: 0;
96+
color: #ff7676;
97+
font-size: 0.85rem;
98+
}
99+
100+
.feedback-success {
101+
display: flex;
102+
flex-direction: column;
103+
gap: 1rem;
104+
font-size: 0.95rem;
105+
color: var(--mode-text-color, #f5f5f5);
106+
align-items: center;
107+
text-align: center;
108+
}
109+
110+
.feedback-success p {
111+
margin: 0;
112+
line-height: 1.5;
113+
}
114+
115+
.feedback-success .feedback-primary-button {
116+
min-width: 220px;
117+
}
118+
119+
@media (max-width: 600px) {
120+
.feedback-actions {
121+
flex-direction: column;
122+
align-items: stretch;
123+
}
124+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { useEffect, useState } from 'react';
2+
import PropTypes from 'prop-types';
3+
import Modal from './Modal';
4+
import './FeedbackModal.css';
5+
import { useAuth } from '../context/AuthContext';
6+
7+
const CATEGORY_OPTIONS = [
8+
{ value: 'feedback', label: 'General feedback' },
9+
{ value: 'bug', label: 'Report a bug' },
10+
{ value: 'idea', label: 'Feature request' },
11+
{ value: 'other', label: 'Something else' }
12+
];
13+
14+
function FeedbackModal({ isOpen, onClose }) {
15+
const { authenticated, user } = useAuth();
16+
const [category, setCategory] = useState('feedback');
17+
const [message, setMessage] = useState('');
18+
const [contactInfo, setContactInfo] = useState('');
19+
const [submitting, setSubmitting] = useState(false);
20+
const [error, setError] = useState('');
21+
const [submitted, setSubmitted] = useState(false);
22+
23+
useEffect(() => {
24+
if (isOpen) {
25+
setCategory('feedback');
26+
setMessage('');
27+
setError('');
28+
setSubmitted(false);
29+
if (authenticated && user?.netid) {
30+
setContactInfo(`${user.netid}@princeton.edu`);
31+
} else {
32+
setContactInfo('');
33+
}
34+
}
35+
}, [isOpen, authenticated, user]);
36+
37+
const closeIfAllowed = () => {
38+
if (!submitting) {
39+
onClose();
40+
}
41+
};
42+
43+
const handleSubmit = async (event) => {
44+
event.preventDefault();
45+
if (submitting) return;
46+
47+
const trimmedMessage = message.trim();
48+
if (trimmedMessage.length < 10) {
49+
setError('Please include at least a few details so we can help.');
50+
return;
51+
}
52+
53+
setSubmitting(true);
54+
setError('');
55+
56+
try {
57+
const response = await fetch('/api/feedback', {
58+
method: 'POST',
59+
headers: { 'Content-Type': 'application/json' },
60+
body: JSON.stringify({
61+
category,
62+
message: trimmedMessage,
63+
contactInfo: contactInfo.trim() || null,
64+
pagePath: typeof window !== 'undefined' ? window.location.pathname : null
65+
})
66+
});
67+
68+
if (!response.ok) {
69+
const data = await response.json().catch(() => ({}));
70+
throw new Error(data.error || 'Unable to send feedback right now.');
71+
}
72+
73+
setSubmitted(true);
74+
setMessage('');
75+
} catch (err) {
76+
setError(err.message || 'Unable to send feedback right now.');
77+
} finally {
78+
setSubmitting(false);
79+
}
80+
};
81+
82+
return (
83+
<Modal
84+
isOpen={isOpen}
85+
onClose={closeIfAllowed}
86+
title={submitted ? 'Thanks for your feedback!' : 'Send Feedback'}
87+
showCloseButton
88+
isLarge={!submitted}
89+
>
90+
{submitted ? (
91+
<div className="feedback-success">
92+
<p>We appreciate you taking the time to help improve TigerType.</p>
93+
<button
94+
type="button"
95+
className="feedback-primary-button"
96+
onClick={closeIfAllowed}
97+
>
98+
Close
99+
</button>
100+
</div>
101+
) : (
102+
<form className="feedback-form" onSubmit={handleSubmit}>
103+
<label>
104+
Category
105+
<select
106+
value={category}
107+
onChange={(event) => setCategory(event.target.value)}
108+
disabled={submitting}
109+
>
110+
{CATEGORY_OPTIONS.map(option => (
111+
<option key={option.value} value={option.value}>
112+
{option.label}
113+
</option>
114+
))}
115+
</select>
116+
</label>
117+
118+
<label>
119+
Describe what happened
120+
<textarea
121+
value={message}
122+
onChange={(event) => setMessage(event.target.value)}
123+
disabled={submitting}
124+
rows={8}
125+
maxLength={2000}
126+
placeholder="Share details, steps to reproduce, or anything else we should know."
127+
/>
128+
<span className="feedback-hint">{message.trim().length}/2000 characters</span>
129+
</label>
130+
131+
<label>
132+
Contact (optional)
133+
<input
134+
type="email"
135+
value={contactInfo}
136+
onChange={(event) => setContactInfo(event.target.value)}
137+
disabled={submitting}
138+
placeholder="we'll follow up here if we need more info"
139+
/>
140+
</label>
141+
142+
{error && <p className="feedback-error">{error}</p>}
143+
144+
<div className="feedback-actions">
145+
<button
146+
type="button"
147+
className="feedback-secondary-button"
148+
onClick={closeIfAllowed}
149+
disabled={submitting}
150+
>
151+
Cancel
152+
</button>
153+
<button
154+
type="submit"
155+
className="feedback-primary-button"
156+
disabled={submitting}
157+
>
158+
{submitting ? 'Sending…' : 'Send feedback'}
159+
</button>
160+
</div>
161+
</form>
162+
)}
163+
</Modal>
164+
);
165+
}
166+
167+
FeedbackModal.propTypes = {
168+
isOpen: PropTypes.bool.isRequired,
169+
onClose: PropTypes.func.isRequired
170+
};
171+
172+
export default FeedbackModal;

client/src/components/Navbar.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,26 @@
2626
gap: 0.5rem;
2727
}
2828

29+
.navbar-feedback-icon {
30+
display: inline-flex;
31+
align-items: center;
32+
justify-content: center;
33+
width: 36px;
34+
height: 36px;
35+
color: var(--mode-text-color);
36+
border-radius: 6px;
37+
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
38+
background: none;
39+
border: none;
40+
cursor: pointer;
41+
}
42+
43+
.navbar-feedback-icon:hover {
44+
background-color: rgba(245, 128, 37, 0.1);
45+
color: #F58025;
46+
transform: translateY(-1px);
47+
}
48+
2949
.settings-button-wrapper {
3050
position: relative;
3151
display: inline-flex;
@@ -104,6 +124,7 @@
104124
transform: rotate(90deg);
105125
}
106126
.navbar-icons button:focus-visible,
127+
.navbar-feedback-icon:focus-visible,
107128
.navbar-github-icon:focus-visible,
108129
.navbar-logo button:focus-visible {
109130
outline: 2px solid #F58025;

0 commit comments

Comments
 (0)