Skip to content

Commit f06afd9

Browse files
Merge pull request #169 from DevLoversTeam/feature/qa-ui
(SP: 2) [Frontend] Q&A UI refresh
2 parents eb2009a + 8814fc3 commit f06afd9

11 files changed

Lines changed: 336 additions & 159 deletions

File tree

frontend/app/[locale]/q&a/page.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Suspense } from 'react';
22
import { getTranslations } from 'next-intl/server';
33
import QaSection from '@/components/q&a/QaSection';
4+
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
45

56
export async function generateMetadata({
67
params,
@@ -16,12 +17,30 @@ export async function generateMetadata({
1617
};
1718
}
1819

19-
export default function QAPage() {
20+
export default async function QAPage({
21+
params,
22+
}: {
23+
params: Promise<{ locale: string }>;
24+
}) {
25+
const { locale } = await params;
26+
const t = await getTranslations({ locale, namespace: 'qa' });
27+
2028
return (
21-
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
22-
<Suspense fallback={<>...</>}>
23-
<QaSection />
24-
</Suspense>
25-
</main>
29+
<DynamicGridBackground className="bg-gray-50 transition-colors duration-300 dark:bg-transparent py-10">
30+
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
31+
<div className="mb-8">
32+
<p className="text-sm text-[var(--accent-primary)] font-semibold">
33+
{t('pretitle')}
34+
</p>
35+
<h1 className="text-3xl font-bold">{t('title')}</h1>
36+
<p className="text-gray-600 dark:text-gray-400">
37+
{t('subtitle')}
38+
</p>
39+
</div>
40+
<Suspense fallback={<>...</>}>
41+
<QaSection />
42+
</Suspense>
43+
</main>
44+
</DynamicGridBackground>
2645
);
2746
}

frontend/components/about/HeroSection.tsx

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,12 @@
11
"use client"
22

3-
import { useRef } from "react"
4-
import { motion, useMotionTemplate, useMotionValue } from "framer-motion"
3+
import { motion } from "framer-motion"
54
import { CheckCircle, Users, Star, Linkedin, ArrowDown } from "lucide-react"
65
import { InteractiveGame } from "./InteractiveGame"
76
import type { PlatformStats } from "@/lib/about/stats"
7+
import { DynamicGridBackground } from "@/components/shared/DynamicGridBackground"
88

99
export function HeroSection({ stats }: { stats?: PlatformStats }) {
10-
const containerRef = useRef<HTMLElement>(null)
11-
12-
const mouseX = useMotionValue(0)
13-
const mouseY = useMotionValue(0)
14-
15-
function handleMouseMove({ currentTarget, clientX, clientY }: React.MouseEvent) {
16-
const { left, top } = currentTarget.getBoundingClientRect()
17-
mouseX.set(clientX - left)
18-
mouseY.set(clientY - top)
19-
}
20-
2110
const data = stats || {
2211
questionsSolved: "850+",
2312
githubStars: "120+",
@@ -26,33 +15,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) {
2615
}
2716

2817
return (
29-
<section
30-
ref={containerRef}
31-
onMouseMove={handleMouseMove}
32-
className="relative flex min-h-[calc(100svh)] items-center justify-center overflow-hidden bg-gray-50 transition-colors duration-300 dark:bg-transparent pt-20 pb-10 group"
33-
>
34-
35-
<div className="pointer-events-none absolute inset-0">
36-
<div className="absolute inset-0 bg-[linear-gradient(to_right,#8080800a_1px,transparent_1px),linear-gradient(to_bottom,#8080800a_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_80%_80%_at_50%_50%,#000_70%,transparent_100%)]" />
37-
</div>
38-
39-
<motion.div
40-
className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
41-
style={{
42-
maskImage: useMotionTemplate`radial-gradient(300px circle at ${mouseX}px ${mouseY}px, black, transparent)`,
43-
WebkitMaskImage: useMotionTemplate`radial-gradient(300px circle at ${mouseX}px ${mouseY}px, black, transparent)`,
44-
}}
45-
>
46-
<div className="absolute inset-0 bg-[linear-gradient(to_right,#1e5eff_1px,transparent_1px),linear-gradient(to_bottom,#1e5eff_1px,transparent_1px)] dark:bg-[linear-gradient(to_right,#ff2d55_1px,transparent_1px),linear-gradient(to_bottom,#ff2d55_1px,transparent_1px)] bg-[size:40px_40px] opacity-20 dark:opacity-30" />
47-
</motion.div>
48-
49-
<div className="pointer-events-none absolute inset-0">
50-
<div className="absolute left-1/2 top-1/2 h-[70svh] w-[70svw] min-h-[500px] min-w-[500px] md:h-[900px] md:w-[900px] -translate-x-1/2 -translate-y-1/2 rounded-full
51-
bg-[#1e5eff]/10 blur-[120px] mix-blend-multiply
52-
dark:bg-[#ff2d55]/10 dark:mix-blend-screen transition-all duration-500"
53-
/>
54-
</div>
55-
18+
<DynamicGridBackground className="flex min-h-[calc(100svh)] items-center justify-center bg-gray-50 transition-colors duration-300 dark:bg-transparent pt-20 pb-10">
5619
<div className="relative z-10 grid w-full max-w-[1600px] grid-cols-1 items-center px-4 sm:px-6 lg:px-8 xl:grid-cols-12 xl:gap-8 h-full">
5720

5821
<div className="hidden h-full flex-col justify-center gap-24 xl:col-span-3 xl:flex">
@@ -121,7 +84,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) {
12184
</div>
12285

12386
</div>
124-
</section>
87+
</DynamicGridBackground>
12588
)
12689
}
12790

@@ -154,4 +117,4 @@ function MobileStatItem({ icon: Icon, color, bg, label, value }: any) {
154117
</div>
155118
</div>
156119
)
157-
}
120+
}

frontend/components/q&a/Pagination.tsx

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,49 @@
11
'use client';
22

3+
import { useEffect, useState } from 'react';
34
import { useTranslations } from 'next-intl';
45
import { cn } from '@/lib/utils';
56

67
interface PaginationProps {
78
currentPage: number;
89
totalPages: number;
910
onPageChange: (page: number) => void;
11+
accentColor: string;
12+
}
13+
14+
function hexToRgba(hex: string, alpha: number): string {
15+
const normalized = hex.replace('#', '');
16+
if (normalized.length !== 6) return `rgba(0, 0, 0, ${alpha})`;
17+
const r = parseInt(normalized.slice(0, 2), 16);
18+
const g = parseInt(normalized.slice(2, 4), 16);
19+
const b = parseInt(normalized.slice(4, 6), 16);
20+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
1021
}
1122

1223
export function Pagination({
1324
currentPage,
1425
totalPages,
1526
onPageChange,
27+
accentColor,
1628
}: PaginationProps) {
1729
const t = useTranslations('qa.pagination');
30+
const accentSoft = hexToRgba(accentColor, 0.16);
31+
const accentGlow = hexToRgba(accentColor, 0.22);
32+
const [isMobile, setIsMobile] = useState(false);
33+
34+
useEffect(() => {
35+
const media = window.matchMedia('(max-width: 640px)');
36+
const update = () => setIsMobile(media.matches);
37+
update();
38+
media.addEventListener('change', update);
39+
return () => media.removeEventListener('change', update);
40+
}, []);
1841

1942
if (totalPages <= 1) return null;
2043

2144
const getPageNumbers = (): (number | 'ellipsis')[] => {
2245
const pages: (number | 'ellipsis')[] = [];
23-
const maxVisible = 5;
46+
const maxVisible = isMobile ? 3 : 5;
2447

2548
if (totalPages <= maxVisible + 2) {
2649
for (let i = 1; i <= totalPages; i++) {
@@ -59,25 +82,32 @@ export function Pagination({
5982

6083
return (
6184
<nav
62-
className="flex items-center justify-center gap-1 mt-8"
85+
className="flex items-center justify-center gap-1 mt-8 sm:gap-2"
86+
style={
87+
{
88+
'--qa-accent': accentColor,
89+
'--qa-accent-soft': accentSoft,
90+
'--qa-accent-glow': accentGlow,
91+
} as React.CSSProperties
92+
}
6393
aria-label={t('label')}
6494
>
6595
<button
6696
onClick={() => onPageChange(currentPage - 1)}
6797
disabled={currentPage === 1}
6898
className={cn(
69-
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
99+
'px-2 py-2 text-sm font-medium rounded-lg transition-colors sm:px-3',
70100
'border border-gray-300 dark:border-gray-700',
71101
currentPage === 1
72102
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
73-
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
103+
: 'text-gray-700 dark:text-gray-300 hover:bg-[var(--qa-accent-soft)]'
74104
)}
75105
aria-label={t('previousPage')}
76106
>
77-
{t('previous')}
107+
<span className="hidden sm:inline">{t('previous')}</span>
78108
</button>
79109

80-
<div className="flex items-center gap-1 mx-2">
110+
<div className="flex items-center gap-1 mx-1 sm:mx-2">
81111
{pages.map((page, index) =>
82112
page === 'ellipsis' ? (
83113
<span
@@ -92,11 +122,20 @@ export function Pagination({
92122
onClick={() => onPageChange(page)}
93123
disabled={page === currentPage}
94124
className={cn(
95-
'min-w-[40px] px-3 py-2 text-sm font-medium rounded-lg transition-colors',
125+
'min-w-[40px] px-3 py-2 text-sm font-medium rounded-lg transition-colors border border-transparent overflow-hidden',
96126
page === currentPage
97-
? 'bg-blue-600 text-white'
98-
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
127+
? 'shadow-sm text-gray-700 dark:text-gray-300'
128+
: 'text-gray-700 dark:text-gray-300 hover:bg-[var(--qa-accent-soft)]'
99129
)}
130+
style={
131+
page === currentPage
132+
? {
133+
backgroundColor: accentSoft,
134+
borderColor: accentColor,
135+
boxShadow: `inset 0 0 18px ${accentGlow}`,
136+
}
137+
: undefined
138+
}
100139
aria-label={t('page', { page })}
101140
aria-current={page === currentPage ? 'page' : undefined}
102141
>
@@ -110,16 +149,16 @@ export function Pagination({
110149
onClick={() => onPageChange(currentPage + 1)}
111150
disabled={currentPage === totalPages}
112151
className={cn(
113-
'px-3 py-2 text-sm font-medium rounded-lg transition-colors',
152+
'px-2 py-2 text-sm font-medium rounded-lg transition-colors sm:px-3',
114153
'border border-gray-300 dark:border-gray-700',
115154
currentPage === totalPages
116155
? 'text-gray-400 dark:text-gray-600 cursor-not-allowed'
117-
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
156+
: 'text-gray-700 dark:text-gray-300 hover:bg-[var(--qa-accent-soft)]'
118157
)}
119158
aria-label={t('nextPage')}
120159
>
121-
{t('next')}
160+
<span className="hidden sm:inline">{t('next')}</span>
122161
</button>
123162
</nav>
124163
);
125-
}
164+
}

frontend/components/q&a/QaSection.tsx

Lines changed: 39 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,74 @@
11
'use client';
22

33
import { useTranslations } from 'next-intl';
4-
import { Search, X } from 'lucide-react';
5-
64
import AccordionList from '@/components/q&a/AccordionList';
75
import { Pagination } from '@/components/q&a/Pagination';
8-
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
6+
import { Tabs, TabsList, TabsContent } from '@/components/ui/tabs';
97
import { categoryData } from '@/data/category';
108
import { useQaTabs } from '@/components/q&a/useQaTabs';
9+
import { QaTabButton } from '@/components/q&a/QaTabButton';
10+
import { qaTabStyles } from '@/data/qaTabs';
11+
import { cn } from '@/lib/utils';
12+
import type { CategorySlug } from '@/components/q&a/types';
1113

1214
export default function TabsSection() {
1315
const t = useTranslations('qa');
1416
const {
1517
active,
1618
currentPage,
17-
debouncedSearch,
1819
handleCategoryChange,
1920
handlePageChange,
2021
isLoading,
2122
items,
2223
localeKey,
23-
searchQuery,
24-
setSearchQuery,
2524
totalPages,
26-
clearSearch,
2725
} = useQaTabs();
2826

2927
return (
3028
<div className="w-full">
31-
<div className="relative mb-6">
32-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
33-
34-
<input
35-
type="text"
36-
value={searchQuery}
37-
onChange={e => setSearchQuery(e.target.value)}
38-
placeholder={t('searchPlaceholder')}
39-
className="w-full pl-10 pr-10 py-3 border rounded-lg"
40-
/>
41-
42-
{searchQuery && (
43-
<button
44-
onClick={clearSearch}
45-
className="absolute right-3 top-1/2 -translate-y-1/2"
46-
>
47-
<X className="h-5 w-5" />
48-
</button>
49-
)}
50-
</div>
51-
5229
<Tabs value={active} onValueChange={handleCategoryChange}>
53-
<TabsList className="!bg-transparent !p-0 !h-auto !w-full grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-10 mb-6 gap-2">
54-
{categoryData.map(category => (
55-
<TabsTrigger
56-
key={category.slug}
57-
value={category.slug}
58-
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
59-
>
60-
{category.translations[localeKey] ??
30+
<TabsList className="!bg-transparent !p-0 !h-auto !w-full flex flex-wrap items-stretch justify-start gap-3 mb-6">
31+
{categoryData.map(category => {
32+
const slug = category.slug as keyof typeof qaTabStyles;
33+
const value = slug as CategorySlug;
34+
return (
35+
<QaTabButton
36+
key={slug}
37+
value={value}
38+
label={
39+
category.translations[localeKey] ??
6140
category.translations.en ??
62-
category.slug}
63-
</TabsTrigger>
64-
))}
41+
value
42+
}
43+
style={qaTabStyles[slug]}
44+
isActive={active === value}
45+
/>
46+
);
47+
})}
6548
</TabsList>
6649

6750
{categoryData.map(category => (
6851
<TabsContent key={category.slug} value={category.slug}>
69-
{isLoading ? (
52+
{isLoading && (
7053
<div className="flex justify-center py-12">
7154
<div className="animate-spin h-8 w-8 border-b-2" />
7255
</div>
73-
) : items.length ? (
74-
<AccordionList items={items} />
75-
) : (
76-
<p className="text-center py-12">
77-
{debouncedSearch
78-
? t('noResults', { query: debouncedSearch })
79-
: t('noQuestions')}
80-
</p>
8156
)}
57+
<div
58+
className={cn(
59+
'transition-opacity duration-300',
60+
isLoading ? 'opacity-0' : 'opacity-100'
61+
)}
62+
aria-busy={isLoading}
63+
>
64+
{items.length ? (
65+
<AccordionList items={items} />
66+
) : (
67+
<p className="text-center py-12">
68+
{t('noQuestions')}
69+
</p>
70+
)}
71+
</div>
8272
</TabsContent>
8373
))}
8474
</Tabs>
@@ -88,6 +78,7 @@ export default function TabsSection() {
8878
currentPage={currentPage}
8979
totalPages={totalPages}
9080
onPageChange={handlePageChange}
81+
accentColor={qaTabStyles[active as keyof typeof qaTabStyles].accent}
9182
/>
9283
)}
9384
</div>

0 commit comments

Comments
 (0)