Skip to content

Commit 23f45f4

Browse files
authored
feat: add feedback form, sponsor recognition and improve dashboard (#334)
1 parent 338deb6 commit 23f45f4

18 files changed

Lines changed: 539 additions & 65 deletions

File tree

frontend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ JANITOR_URL=
6161
# --- Quiz
6262
QUIZ_ENCRYPTION_KEY=
6363

64+
# --- Web3Forms (feedback form)
65+
NEXT_PUBLIC_WEB3FORMS_KEY=
66+
67+
GITHUB_SPONSORS_TOKEN=
68+
6469
# --- Telegram
6570
TELEGRAM_BOT_TOKEN=
6671
TELEGRAM_CHAT_ID=

frontend/app/[locale]/dashboard/page.tsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getTranslations } from 'next-intl/server';
22

33
import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync';
44
import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard';
5+
import { FeedbackForm } from '@/components/dashboard/FeedbackForm';
56
import { ProfileCard } from '@/components/dashboard/ProfileCard';
67
import { QuizResultsSection } from '@/components/dashboard/QuizResultsSection';
78
import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
@@ -10,6 +11,7 @@ import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground
1011
import { getUserLastAttemptPerQuiz, getUserQuizStats } from '@/db/queries/quiz';
1112
import { getUserProfile } from '@/db/queries/users';
1213
import { redirect } from '@/i18n/routing';
14+
import { getSponsors } from '@/lib/about/github-sponsors';
1315
import { getCurrentUser } from '@/lib/auth';
1416

1517
export async function generateMetadata({
@@ -46,6 +48,24 @@ export default async function DashboardPage({
4648

4749
const t = await getTranslations('dashboard');
4850

51+
const sponsors = await getSponsors();
52+
const userEmail = user.email.toLowerCase();
53+
const userName = (user.name ?? '').toLowerCase();
54+
const userImage = user.image ?? '';
55+
const matchedSponsor = sponsors.find(s => {
56+
if (s.email && s.email.toLowerCase() === userEmail) return true;
57+
if (userName && s.login && s.login.toLowerCase() === userName) return true;
58+
if (userName && s.name && s.name.toLowerCase() === userName) return true;
59+
if (
60+
userImage &&
61+
s.avatarUrl &&
62+
s.avatarUrl.trim().length > 0 &&
63+
userImage.includes(s.avatarUrl.split('?')[0])
64+
)
65+
return true;
66+
return false;
67+
});
68+
4969
const attempts = await getUserQuizStats(session.id);
5070
const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale);
5171

@@ -101,24 +121,32 @@ export default async function DashboardPage({
101121
</div>
102122

103123
<a
104-
href="https://t.me/devloversteam"
105-
target="_blank"
106-
rel="noopener noreferrer"
124+
href="#feedback"
107125
className={outlineBtnStyles}
108126
>
109127
{t('supportLink')}
110128
</a>
111129
</header>
112130
<QuizSavedBanner />
113131
<div className="grid gap-8 md:grid-cols-2">
114-
<ProfileCard user={userForDisplay} locale={locale} />
132+
<ProfileCard
133+
user={userForDisplay}
134+
locale={locale}
135+
isSponsor={!!matchedSponsor}
136+
/>
115137
<StatsCard stats={stats} />
116138
</div>
117139
<div className="mt-8">
118-
<ExplainedTermsCard />
140+
<QuizResultsSection attempts={lastAttempts} locale={locale} />
119141
</div>
120142
<div className="mt-8">
121-
<QuizResultsSection attempts={lastAttempts} locale={locale} />
143+
<ExplainedTermsCard />
144+
</div>
145+
<div id="feedback" className="mt-8 scroll-mt-24">
146+
<FeedbackForm
147+
userName={userForDisplay.name}
148+
userEmail={userForDisplay.email}
149+
/>
122150
</div>
123151
</main>
124152
</DynamicGridBackground>

frontend/app/[locale]/leaderboard/page.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Metadata } from 'next';
22

33
import LeaderboardClient from '@/components/leaderboard/LeaderboardClient';
44
import { getLeaderboardData } from '@/db/queries/leaderboard';
5+
import { getSponsors } from '@/lib/about/github-sponsors';
56
import { getCurrentUser } from '@/lib/auth';
67

78
export const metadata: Metadata = {
@@ -12,8 +13,24 @@ export const metadata: Metadata = {
1213
export const dynamic = 'force-dynamic';
1314

1415
export default async function LeaderboardPage() {
15-
const users = await getLeaderboardData();
16-
const session = await getCurrentUser();
16+
const [rows, session, sponsors] = await Promise.all([
17+
getLeaderboardData(),
18+
getCurrentUser(),
19+
getSponsors(),
20+
]);
21+
22+
const users = rows.map(({ email, ...user }) => {
23+
const emailLower = email.toLowerCase();
24+
const nameLower = user.username.toLowerCase();
25+
const isSponsor = sponsors.some(
26+
s =>
27+
(s.email && s.email.toLowerCase() === emailLower) ||
28+
(nameLower && s.login.toLowerCase() === nameLower) ||
29+
(nameLower && s.name.toLowerCase() === nameLower) ||
30+
(user.avatar && s.avatarUrl && user.avatar.includes(s.avatarUrl.split('?')[0]))
31+
);
32+
return { ...user, isSponsor };
33+
});
1734

1835
return <LeaderboardClient initialUsers={users} currentUser={session} />;
1936
}

frontend/components/dashboard/ExplainedTermsCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,9 @@ export function ExplainedTermsCard() {
230230

231231
const cardStyles = `
232232
relative overflow-hidden rounded-2xl
233-
border border-gray-100 dark:border-white/5
233+
border border-gray-200 dark:border-white/10
234234
bg-white/60 dark:bg-neutral-900/60 backdrop-blur-xl
235-
p-8 transition-all hover:border-[var(--accent-primary)]/30 dark:hover:border-[var(--accent-primary)]/30
235+
p-4 sm:p-6 md:p-8 transition-all hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30
236236
`;
237237

238238
return (
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
'use client';
2+
3+
import { ChevronDown, MessageSquare, Send } from 'lucide-react';
4+
import { useTranslations } from 'next-intl';
5+
import { useEffect, useRef, useState } from 'react';
6+
7+
interface FeedbackFormProps {
8+
userName?: string | null;
9+
userEmail?: string | null;
10+
}
11+
12+
export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) {
13+
const t = useTranslations('dashboard.feedback');
14+
const [loading, setLoading] = useState(false);
15+
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
16+
const [categoryOpen, setCategoryOpen] = useState(false);
17+
const [category, setCategory] = useState('');
18+
const [categoryError, setCategoryError] = useState(false);
19+
const categoryRef = useRef<HTMLDivElement>(null);
20+
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21+
22+
const categories = [
23+
{ value: 'Bug Report', label: t('categoryBug') },
24+
{ value: 'Suggestion', label: t('categorySuggestion') },
25+
{ value: 'Question', label: t('categoryQuestion') },
26+
{ value: 'Other', label: t('categoryOther') },
27+
];
28+
29+
const selectedLabel = categories.find(c => c.value === category)?.label;
30+
31+
useEffect(() => {
32+
const handleClickOutside = (event: MouseEvent) => {
33+
if (
34+
categoryRef.current &&
35+
!categoryRef.current.contains(event.target as Node)
36+
) {
37+
setCategoryOpen(false);
38+
}
39+
};
40+
41+
const handleSmoothScroll = (e: MouseEvent) => {
42+
const target = e.target as HTMLElement;
43+
const anchor = target.closest('a[href="#feedback"]');
44+
if (anchor) {
45+
e.preventDefault();
46+
document
47+
.getElementById('feedback')
48+
?.scrollIntoView({ behavior: 'smooth' });
49+
}
50+
};
51+
52+
document.addEventListener('mousedown', handleClickOutside);
53+
document.addEventListener('click', handleSmoothScroll);
54+
return () => {
55+
document.removeEventListener('mousedown', handleClickOutside);
56+
document.removeEventListener('click', handleSmoothScroll);
57+
if (successTimerRef.current) clearTimeout(successTimerRef.current);
58+
};
59+
}, []);
60+
61+
const cardStyles = `
62+
relative overflow-hidden rounded-2xl
63+
border border-gray-200 dark:border-white/10
64+
bg-white/60 dark:bg-neutral-900/60 backdrop-blur-xl
65+
p-4 sm:p-6 md:p-8 transition-all hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30
66+
`;
67+
68+
const inputStyles =
69+
'w-full rounded-xl border border-gray-200 dark:border-white/10 bg-white/50 dark:bg-neutral-800/50 px-4 py-3 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none transition-colors focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary)';
70+
71+
const primaryBtnStyles = `
72+
group relative inline-flex items-center justify-center gap-2 rounded-full
73+
px-8 py-3 text-sm font-semibold tracking-widest uppercase text-white
74+
bg-(--accent-primary) hover:bg-(--accent-hover)
75+
transition-all hover:scale-105 disabled:opacity-50 disabled:hover:scale-100
76+
`;
77+
78+
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
79+
e.preventDefault();
80+
if (!category) {
81+
setCategoryError(true);
82+
return;
83+
}
84+
setCategoryError(false);
85+
setLoading(true);
86+
setStatus('idle');
87+
88+
const accessKey = process.env.NEXT_PUBLIC_WEB3FORMS_KEY;
89+
if (!accessKey) {
90+
console.error(
91+
'FeedbackForm: NEXT_PUBLIC_WEB3FORMS_KEY is not defined. Add it to your .env file.'
92+
);
93+
setStatus('error');
94+
setLoading(false);
95+
return;
96+
}
97+
98+
const formData = new FormData(e.currentTarget);
99+
100+
const data = {
101+
access_key: accessKey,
102+
subject: `DevLovers Feedback: ${formData.get('category')}`,
103+
from_name: formData.get('name'),
104+
email: formData.get('email'),
105+
category: formData.get('category'),
106+
message: formData.get('message'),
107+
botcheck: '',
108+
};
109+
110+
try {
111+
const res = await fetch('https://api.web3forms.com/submit', {
112+
method: 'POST',
113+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
114+
body: JSON.stringify(data),
115+
});
116+
117+
const result = await res.json();
118+
119+
if (result.success) {
120+
setStatus('success');
121+
setCategory('');
122+
(e.target as HTMLFormElement).reset();
123+
successTimerRef.current = setTimeout(() => setStatus('idle'), 5000);
124+
} else {
125+
setStatus('error');
126+
}
127+
} catch {
128+
setStatus('error');
129+
} finally {
130+
setLoading(false);
131+
}
132+
}
133+
134+
return (
135+
<section className={cardStyles} aria-labelledby="feedback-heading">
136+
<div className="mb-6 flex items-center gap-3">
137+
<div
138+
className="rounded-full bg-gray-100 p-3 dark:bg-neutral-800/50"
139+
aria-hidden="true"
140+
>
141+
<MessageSquare className="h-5 w-5 text-(--accent-primary)" />
142+
</div>
143+
<div>
144+
<h3
145+
id="feedback-heading"
146+
className="text-xl font-bold text-gray-900 dark:text-white"
147+
>
148+
{t('title')}
149+
</h3>
150+
<p className="text-sm text-gray-500 dark:text-gray-400">
151+
{t('description')}
152+
</p>
153+
</div>
154+
</div>
155+
156+
{status === 'success' ? (
157+
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm font-medium text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400">
158+
{t('success')}
159+
</div>
160+
) : (
161+
<form onSubmit={onSubmit} className="space-y-4">
162+
<input type="hidden" name="botcheck" className="hidden" />
163+
164+
<div className="grid gap-4 sm:grid-cols-2">
165+
<input
166+
name="name"
167+
type="text"
168+
placeholder={t('name')}
169+
defaultValue={userName ?? ''}
170+
required
171+
onInvalid={e => (e.target as HTMLInputElement).setCustomValidity(t('requiredField'))}
172+
onInput={e => (e.target as HTMLInputElement).setCustomValidity('')}
173+
className={inputStyles}
174+
/>
175+
<input
176+
name="email"
177+
type="email"
178+
placeholder={t('email')}
179+
defaultValue={userEmail ?? ''}
180+
required
181+
onInvalid={e => (e.target as HTMLInputElement).setCustomValidity(t('requiredField'))}
182+
onInput={e => (e.target as HTMLInputElement).setCustomValidity('')}
183+
className={inputStyles}
184+
/>
185+
</div>
186+
187+
<input type="hidden" name="category" value={category} />
188+
<div className="relative" ref={categoryRef}>
189+
<button
190+
type="button"
191+
onClick={() => setCategoryOpen(!categoryOpen)}
192+
className={`flex w-full items-center justify-between rounded-xl border bg-gray-50/50 px-4 py-3 text-left text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:bg-neutral-800/50 dark:text-gray-300 dark:hover:bg-neutral-800 ${categoryError ? 'border-red-400 dark:border-red-500/50' : 'border-gray-200 dark:border-white/5'}`}
193+
>
194+
<span className={selectedLabel ? '' : 'text-gray-400 dark:text-gray-500'}>
195+
{selectedLabel ?? t('category')}
196+
</span>
197+
<ChevronDown
198+
className={`h-4 w-4 transition-transform ${categoryOpen ? 'rotate-180' : ''}`}
199+
/>
200+
</button>
201+
{categoryOpen && (
202+
<div className="absolute left-0 z-50 mt-1 w-full rounded-xl border border-gray-200 bg-white py-1 shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
203+
{categories.map(c => (
204+
<button
205+
key={c.value}
206+
type="button"
207+
onClick={() => {
208+
setCategory(c.value);
209+
setCategoryError(false);
210+
setCategoryOpen(false);
211+
}}
212+
className={`block w-full px-4 py-2 text-left text-sm transition-colors ${
213+
category === c.value
214+
? 'bg-(--accent-primary)/10 font-medium text-(--accent-primary)'
215+
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-neutral-800'
216+
}`}
217+
>
218+
{c.label}
219+
</button>
220+
))}
221+
</div>
222+
)}
223+
{categoryError && (
224+
<p className="mt-1 text-xs text-red-500 dark:text-red-400">
225+
{t('requiredField')}
226+
</p>
227+
)}
228+
</div>
229+
230+
<textarea
231+
name="message"
232+
placeholder={t('messagePlaceholder')}
233+
required
234+
onInvalid={e => (e.target as HTMLTextAreaElement).setCustomValidity(t('requiredField'))}
235+
onInput={e => (e.target as HTMLTextAreaElement).setCustomValidity('')}
236+
rows={4}
237+
className={`${inputStyles} resize-none`}
238+
/>
239+
240+
{status === 'error' && (
241+
<div className="rounded-xl border border-red-200 bg-red-50 p-3 text-sm font-medium text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400">
242+
{t('error')}
243+
</div>
244+
)}
245+
246+
<div className="flex justify-center">
247+
<button type="submit" disabled={loading} className={primaryBtnStyles}>
248+
<Send className="h-4 w-4" />
249+
<span>{loading ? t('submitting') : t('submit')}</span>
250+
</button>
251+
</div>
252+
</form>
253+
)}
254+
</section>
255+
);
256+
}

0 commit comments

Comments
 (0)