diff --git a/.env.example b/.env.example index 3837642e..12f40f07 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ DB_HOST=localhost DB_PORT=5432 DB_NAME=tigertype +## Feedback Email +FEEDBACK_EMAIL_FROM=cs-tigertype@princeton.edu +FEEDBACK_EMAIL_TO_TEAM=cs-tigertype@princeton.edu,it.admin@tigerapps.org +FEEDBACK_REPLY_TO=cs-tigertype@princeton.edu,it.admin@tigerapps.org +SITE_URL=https://tigertype.tigerapps.org + # Scraping Configuration OPENAI_API_KEY=your_api_key_here PRINCETON_API_KEY="" @@ -43,3 +49,11 @@ CHANGELOG_PUBLISH_TOKEN=your_shared_secret_token # Start PostgreSQL service # brew services start postgresql + +# Delegated SMTP OAuth (device code, cached) +# Run: node server/scripts/seed_smtp_oauth_device_login.js (saves a cache file and prints JSON) +# For Heroku, set SMTP_OAUTH_CACHE to the printed JSON +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +SMTP_SENDER=cs-tigertype@princeton.edu +SMTP_OAUTH_CACHE= (optional; JSON blob of MSAL cache for headless servers) diff --git a/.gitignore b/.gitignore index d55778a4..b5643184 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ AGENTS.* # Environment and Configuration .env .venv +server/.smtp_oauth_cache.json # Dependencies node_modules @@ -34,4 +35,4 @@ __pycache__ .gitattributes # Miscellaneous -.bak \ No newline at end of file +.bak diff --git a/client/src/components/FeedbackModal.css b/client/src/components/FeedbackModal.css new file mode 100644 index 00000000..163e088f --- /dev/null +++ b/client/src/components/FeedbackModal.css @@ -0,0 +1,124 @@ +.feedback-form { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.feedback-form label { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.95rem; + color: var(--mode-text-color, #f5f5f5); +} + +.feedback-form select, +.feedback-form textarea, +.feedback-form input { + width: 100%; + padding: 0.75rem; + border-radius: 8px; + border: 1px solid rgba(245, 128, 37, 0.25); + background: var(--input-bg, rgba(18, 18, 18, 0.85)); + color: var(--mode-text-color, #f5f5f5); + font-family: inherit; + font-size: 0.95rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.feedback-form select:focus, +.feedback-form textarea:focus, +.feedback-form input:focus { + outline: none; + border-color: #F58025; + box-shadow: 0 0 0 2px rgba(245, 128, 37, 0.2); +} + +.feedback-form textarea { + min-height: 160px; + resize: vertical; +} + +.feedback-hint { + font-size: 0.8rem; + color: var(--subtle-text-color, rgba(255, 255, 255, 0.6)); + align-self: flex-end; +} + +.feedback-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.feedback-primary-button, +.feedback-secondary-button { + padding: 0.65rem 1.4rem; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; + border: none; +} + +.feedback-primary-button { + background-color: #F58025; + color: #121212; + box-shadow: 0 2px 8px rgba(245, 128, 37, 0.35); +} + +.feedback-primary-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(245, 128, 37, 0.4); +} + +.feedback-secondary-button { + background: transparent; + border: 1px solid rgba(245, 128, 37, 0.5); + color: var(--mode-text-color, #f5f5f5); +} + +.feedback-secondary-button:hover:not(:disabled) { + transform: translateY(-1px); + border-color: #F58025; +} + +.feedback-primary-button:disabled, +.feedback-secondary-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.feedback-error { + margin: 0; + color: #ff7676; + font-size: 0.85rem; +} + +.feedback-success { + display: flex; + flex-direction: column; + gap: 1rem; + font-size: 0.95rem; + color: var(--mode-text-color, #f5f5f5); + align-items: center; + text-align: center; +} + +.feedback-success p { + margin: 0; + line-height: 1.5; +} + +.feedback-success .feedback-primary-button { + min-width: 220px; +} + +@media (max-width: 600px) { + .feedback-actions { + flex-direction: column; + align-items: stretch; + } +} diff --git a/client/src/components/FeedbackModal.jsx b/client/src/components/FeedbackModal.jsx new file mode 100644 index 00000000..cb9130b0 --- /dev/null +++ b/client/src/components/FeedbackModal.jsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import Modal from './Modal'; +import './FeedbackModal.css'; +import { useAuth } from '../context/AuthContext'; + +const CATEGORY_OPTIONS = [ + { value: 'feedback', label: 'General feedback' }, + { value: 'bug', label: 'Report a bug' }, + { value: 'idea', label: 'Feature request' }, + { value: 'other', label: 'Something else' } +]; + +function FeedbackModal({ isOpen, onClose }) { + const { authenticated, user } = useAuth(); + const [category, setCategory] = useState('feedback'); + const [message, setMessage] = useState(''); + const [contactInfo, setContactInfo] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (isOpen) { + setCategory('feedback'); + setMessage(''); + setError(''); + setSubmitted(false); + if (authenticated && user?.netid) { + setContactInfo(`${user.netid}@princeton.edu`); + } else { + setContactInfo(''); + } + } + }, [isOpen, authenticated, user]); + + const closeIfAllowed = () => { + if (!submitting) { + onClose(); + } + }; + + const handleSubmit = async (event) => { + event.preventDefault(); + if (submitting) return; + + const trimmedMessage = message.trim(); + if (trimmedMessage.length < 10) { + setError('Please include at least a few details so we can help.'); + return; + } + + setSubmitting(true); + setError(''); + + try { + const response = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + category, + message: trimmedMessage, + contactInfo: contactInfo.trim() || null, + pagePath: typeof window !== 'undefined' ? window.location.pathname : null + }) + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Unable to send feedback right now.'); + } + + setSubmitted(true); + setMessage(''); + } catch (err) { + setError(err.message || 'Unable to send feedback right now.'); + } finally { + setSubmitting(false); + } + }; + + return ( + + {submitted ? ( +
+

We appreciate you taking the time to help improve TigerType.

+ +
+ ) : ( +
+ + +