-
+
🎉 {t('title')}
-
+
{t('scored')}{' '}
{info.score}/{info.total}
@@ -62,13 +62,13 @@ export function QuizSavedBanner() {
{t('viewLeaderboard')}
{t('tryAgain')}
diff --git a/frontend/components/dashboard/StatsCard.tsx b/frontend/components/dashboard/StatsCard.tsx
index 2679c3f1..f989d1cd 100644
--- a/frontend/components/dashboard/StatsCard.tsx
+++ b/frontend/components/dashboard/StatsCard.tsx
@@ -18,28 +18,24 @@ export function StatsCard({ stats }: StatsCardProps) {
const hasActivity = stats && stats.totalAttempts > 0;
const cardStyles = `
- relative overflow-hidden rounded-[2rem]
- border border-slate-200/70 dark:border-slate-700/80
- bg-white/60 dark:bg-slate-900/60 backdrop-blur-md
- shadow-[0_18px_45px_rgba(15,23,42,0.05)]
- dark:shadow-[0_22px_60px_rgba(0,0,0,0.2)]
- p-8 transition-all hover:border-sky-200 dark:hover:border-sky-800
+ relative overflow-hidden rounded-2xl
+ border border-gray-100 dark:border-white/5
+ bg-white/60 dark:bg-neutral-900/60 backdrop-blur-xl
+ p-8 transition-all hover:border-[var(--accent-primary)]/30 dark:hover:border-[var(--accent-primary)]/30
flex flex-col items-center justify-center text-center
`;
const primaryBtnStyles = `
group relative inline-flex items-center justify-center rounded-full
px-8 py-3 text-sm font-semibold tracking-widest uppercase text-white
- bg-gradient-to-r from-sky-500 via-indigo-500 to-pink-500
- shadow-[0_4px_14px_rgba(56,189,248,0.4)]
- dark:shadow-[0_4px_20px_rgba(129,140,248,0.4)]
- transition-all hover:scale-105 hover:shadow-lg
+ bg-[var(--accent-primary)] hover:bg-[var(--accent-hover)]
+ transition-all hover:scale-105
`;
return (
📊
@@ -47,40 +43,36 @@ export function StatsCard({ stats }: StatsCardProps) {
{t('title')}
{!hasActivity ? (
<>
-
+
{t('noActivity')}
{t('startQuiz')}
-
>
) : (
-
-
-
+
+
-
{t('attempts')}
- -
+
-
{stats?.totalAttempts}
-
-
-
+
+
-
{t('avgScore')}
- -
+
-
{stats?.averageScore}%
diff --git a/frontend/components/home/CodeCard.tsx b/frontend/components/home/CodeCard.tsx
index 1e60b02f..0f658566 100644
--- a/frontend/components/home/CodeCard.tsx
+++ b/frontend/components/home/CodeCard.tsx
@@ -18,17 +18,19 @@ export function CodeCard({ fileName, snippet, className }: CodeCardProps) {
-
-
+
+
-
-
-
+
+
+
-
{fileName}
+
+ {fileName}
+
-
+
{snippet}
diff --git a/frontend/components/home/HeroCodeCards.tsx b/frontend/components/home/HeroCodeCards.tsx
index 106a2e95..e8e21244 100644
--- a/frontend/components/home/HeroCodeCards.tsx
+++ b/frontend/components/home/HeroCodeCards.tsx
@@ -5,7 +5,7 @@ export function HeroCodeCards() {
<>
type Arr1 = [
@@ -31,7 +31,7 @@ export function HeroCodeCards() {
function sum(
diff --git a/frontend/components/home/HeroSection.tsx b/frontend/components/home/HeroSection.tsx
index 90998053..eba79791 100644
--- a/frontend/components/home/HeroSection.tsx
+++ b/frontend/components/home/HeroSection.tsx
@@ -1,16 +1,20 @@
'use client';
import { useTranslations } from 'next-intl';
+import * as React from 'react';
+
+import { OnlineCounterPopup } from '@/components/shared/OnlineCounterPopup';
import { HeroBackground } from './HeroBackground';
import { HeroCodeCards } from './HeroCodeCards';
import { InteractiveCTAButton } from './InteractiveCTAButton';
export default function HeroSection() {
+ const ctaRef = React.useRef(null);
const t = useTranslations('homepage');
return (
-
+
@@ -40,7 +44,8 @@ export default function HeroSection() {
-
+
+
diff --git a/frontend/components/home/InteractiveCTAButton.tsx b/frontend/components/home/InteractiveCTAButton.tsx
index 80d3dc2d..381ddb74 100644
--- a/frontend/components/home/InteractiveCTAButton.tsx
+++ b/frontend/components/home/InteractiveCTAButton.tsx
@@ -3,171 +3,163 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Heart } from 'lucide-react';
import { useTranslations } from 'next-intl';
-import React from 'react';
+import * as React from 'react';
import { Link } from '@/i18n/routing';
-export function InteractiveCTAButton() {
- const t = useTranslations('homepage');
+export const InteractiveCTAButton = React.forwardRef(
+ function InteractiveCTAButton(_, ref) {
+ const t = useTranslations('homepage');
- const [variantIndex, setVariantIndex] = React.useState(1);
- const [isHovered, setIsHovered] = React.useState(false);
+ const [variantIndex, setVariantIndex] = React.useState(1);
+ const [isHovered, setIsHovered] = React.useState(false);
+ const [currentText, setCurrentText] = React.useState(t('cta'));
+ const [isFirstRender, setIsFirstRender] = React.useState(true);
- const textVariants = [
- t('ctaVariants.1'),
- t('ctaVariants.2'),
- t('ctaVariants.3'),
- t('ctaVariants.4'),
- t('ctaVariants.5'),
- t('ctaVariants.6'),
- t('ctaVariants.7'),
- t('ctaVariants.8'),
- ];
+ const textVariants = [
+ t('ctaVariants.1'),
+ t('ctaVariants.2'),
+ t('ctaVariants.3'),
+ t('ctaVariants.4'),
+ t('ctaVariants.5'),
+ t('ctaVariants.6'),
+ t('ctaVariants.7'),
+ t('ctaVariants.8'),
+ ];
- const defaultVariant = t('cta');
- const hoverVariant = textVariants[variantIndex];
+ React.useEffect(() => {
+ setIsFirstRender(false);
+ }, []);
- const handleEnter = () => {
- if (!window.matchMedia('(hover: hover)').matches) return;
- setIsHovered(true);
- };
+ const handleEnter = () => {
+ if (!window.matchMedia('(hover: hover)').matches) return;
+ setIsHovered(true);
+ setCurrentText(textVariants[variantIndex]);
+ };
- const handleLeave = () => {
- if (!window.matchMedia('(hover: hover)').matches) return;
- setIsHovered(false);
- setVariantIndex(prev => (prev + 1) % textVariants.length);
- };
+ const handleLeave = () => {
+ if (!window.matchMedia('(hover: hover)').matches) return;
+ setIsHovered(false);
+ setVariantIndex(prev => (prev + 1) % textVariants.length);
+ };
- const particles = Array.from({ length: 12 }, (_, i) => ({
- id: i,
- angle: (i * 360) / 8,
- }));
+ const particles = Array.from({ length: 12 }, (_, i) => ({
+ id: i,
+ angle: (i * 360) / 12,
+ }));
- return (
-
- {/* Базовий градієнт з анімацією */}
-
+ return (
+
+
- {/* М'яка неонова підсвітка */}
-
+ }}
+ />
- {/* Орбітальні частинки - БЛИЗЬКО навколо кнопки */}
-
- {isHovered &&
- particles.map(particle => (
-
-
+ {isHovered &&
+ particles.map(particle => (
+
-
- ))}
-
+ initial={{ x: 0, y: 0, scale: 0, opacity: 0 }}
+ animate={{
+ x: [0, Math.cos((particle.angle * Math.PI) / 180) * 230, 0],
+ y: [0, Math.sin((particle.angle * Math.PI) / 180) * 60, 0],
+ scale: [0.7, 1, 0.7],
+ opacity: [0, 0.9, 0],
+ }}
+ transition={{
+ duration: 2,
+ repeat: Infinity,
+ ease: 'easeInOut',
+ delay: particle.id * 0.1,
+ }}
+ >
+
+
+ ))}
+
- {/* Внутрішній shine overlay */}
-
+
- {/* Текст з анімацією */}
-
-
- {!isHovered ? (
+
+
- {defaultVariant}
+ {currentText}
- ) : (
-
- {hoverVariant}
-
- )}
-
-
-
- );
-}
+
+
+
+ );
+ }
+);
diff --git a/frontend/components/q&a/AIWordHelper.tsx b/frontend/components/q&a/AIWordHelper.tsx
index 9a31059a..2f2e35b0 100644
--- a/frontend/components/q&a/AIWordHelper.tsx
+++ b/frontend/components/q&a/AIWordHelper.tsx
@@ -373,7 +373,7 @@ export default function AIWordHelper({
const renderGuestCTA = () => (
-
+
@@ -387,8 +387,8 @@ export default function AIWordHelper({
href="/login"
className={cn(
'rounded-lg px-4 py-2 text-sm font-medium',
- 'bg-[var(--accent-primary)] text-white',
- 'hover:bg-[var(--accent-hover)]',
+ 'bg-(--accent-primary) text-white',
+ 'hover:bg-(--accent-hover)',
'transition-colors'
)}
>
@@ -468,7 +468,7 @@ export default function AIWordHelper({
'text-gray-500 dark:text-gray-400',
'hover:bg-gray-100 dark:hover:bg-neutral-800',
'transition-colors',
- 'focus:ring-2 focus:ring-[var(--accent-primary)] focus:outline-none'
+ 'focus:ring-2 focus:ring-(--accent-primary) focus:outline-none'
)}
aria-label={t('close')}
>
@@ -494,7 +494,7 @@ export default function AIWordHelper({
className={cn(
'rounded-lg px-4 py-2 text-sm font-medium transition-colors',
activeLocale === loc
- ? 'bg-[var(--accent-primary)] text-white'
+ ? 'bg-(--accent-primary) text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-neutral-800 dark:text-gray-300 dark:hover:bg-neutral-700'
)}
>
@@ -506,7 +506,7 @@ export default function AIWordHelper({
{isLoading && (
-
+
{t('loading')}
@@ -560,7 +560,7 @@ export default function AIWordHelper({
? 'cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
: rateLimitState.retryAttempts >= 3
? 'bg-amber-500 text-white hover:bg-amber-600'
- : 'bg-[var(--accent-primary)] text-white hover:bg-[var(--accent-hover)]'
+ : 'bg-(--accent-primary) text-white hover:bg-(--accent-hover)'
)}
>
{rateLimitState.retryAttempts >= 5 ? (
@@ -644,7 +644,7 @@ export default function AIWordHelper({
rel="noopener noreferrer"
className={cn(
'flex items-center gap-3 rounded-lg px-4 py-3',
- 'bg-gradient-to-r from-pink-50 to-purple-50 dark:from-pink-900/20 dark:to-purple-900/20',
+ 'bg-linear-to-r from-pink-50 to-purple-50 dark:from-pink-900/20 dark:to-purple-900/20',
'hover:from-pink-100 hover:to-purple-100 dark:hover:from-pink-900/30 dark:hover:to-purple-900/30',
'border border-pink-200 dark:border-pink-800',
'transition-colors',
@@ -723,7 +723,7 @@ export default function AIWordHelper({
? 'cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400'
: serviceErrorState.retryAttempts >= 3
? 'bg-blue-500 text-white hover:bg-blue-600'
- : 'bg-[var(--accent-primary)] text-white hover:bg-[var(--accent-hover)]'
+ : 'bg-(--accent-primary) text-white hover:bg-(--accent-hover)'
)}
>
{serviceErrorState.retryAttempts >= 5 ? (
@@ -753,8 +753,8 @@ export default function AIWordHelper({
onClick={fetchExplanation}
className={cn(
'flex items-center gap-2 rounded-lg px-4 py-2',
- 'bg-[var(--accent-primary)] text-white',
- 'hover:bg-[var(--accent-hover)]',
+ 'bg-(--accent-primary) text-white',
+ 'hover:bg-(--accent-hover)',
'transition-colors',
'text-sm font-medium'
)}
@@ -770,7 +770,7 @@ export default function AIWordHelper({
{explanation && !isLoading && !error && (
-
+
{formatExplanation(explanation[activeLocale])}
diff --git a/frontend/components/q&a/AccordionList.tsx b/frontend/components/q&a/AccordionList.tsx
index c68a50b7..62fb62d7 100644
--- a/frontend/components/q&a/AccordionList.tsx
+++ b/frontend/components/q&a/AccordionList.tsx
@@ -244,7 +244,7 @@ function renderTable(
): ReactNode {
return (
-
+
{block.header.map((cell, i) => (
@@ -363,6 +363,14 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
onTermClick: handleCachedTermClick,
};
+ const clearSelection = useCallback(() => {
+ if (typeof window === 'undefined') return;
+ const selection = window.getSelection?.();
+ if (selection && !selection.isCollapsed) {
+ selection.removeAllRanges();
+ }
+ }, []);
+
return (
<>
@@ -382,7 +390,10 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
: undefined
}
>
-
+
{q.question}
diff --git a/frontend/components/q&a/FloatingExplainButton.tsx b/frontend/components/q&a/FloatingExplainButton.tsx
index e04d6384..dea9c5c6 100644
--- a/frontend/components/q&a/FloatingExplainButton.tsx
+++ b/frontend/components/q&a/FloatingExplainButton.tsx
@@ -58,14 +58,14 @@ export default function FloatingExplainButton({
'fixed z-50',
'px-3 py-1.5',
'text-sm font-medium',
- 'bg-[var(--accent-primary)] text-white',
+ 'bg-(--accent-primary) text-white',
'rounded-full',
'border border-transparent',
'shadow-lg',
- 'hover:bg-[var(--accent-hover)]',
+ 'hover:bg-(--accent-hover)',
'transition-all duration-200',
'animate-in fade-in-0 zoom-in-95',
- 'focus:ring-2 focus:ring-[var(--accent-primary)] focus:ring-offset-2 focus:outline-none'
+ 'focus:ring-2 focus:ring-(--accent-primary) focus:ring-offset-2 focus:outline-none'
)}
style={{
left: `${position.x}px`,
diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx
index ddbc64af..e16c7cc9 100644
--- a/frontend/components/q&a/QaSection.tsx
+++ b/frontend/components/q&a/QaSection.tsx
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
+import { useCallback, useEffect, useRef } from 'react';
import AccordionList from '@/components/q&a/AccordionList';
import { Pagination } from '@/components/q&a/Pagination';
@@ -14,6 +15,8 @@ import { cn } from '@/lib/utils';
export default function TabsSection() {
const t = useTranslations('qa');
+ const sectionRef = useRef(null);
+ const pendingScrollRef = useRef(false);
const {
active,
currentPage,
@@ -25,8 +28,37 @@ export default function TabsSection() {
totalPages,
} = useQaTabs();
+ const clearSelection = useCallback(() => {
+ if (typeof window === 'undefined') return;
+ const selection = window.getSelection?.();
+ if (selection && !selection.isCollapsed) {
+ selection.removeAllRanges();
+ }
+ }, []);
+
+ const onPageChange = useCallback(
+ (page: number) => {
+ clearSelection();
+ pendingScrollRef.current = true;
+ handlePageChange(page);
+ },
+ [clearSelection, handlePageChange]
+ );
+
+ useEffect(() => {
+ if (!pendingScrollRef.current || isLoading) return;
+ pendingScrollRef.current = false;
+ const frame = window.requestAnimationFrame(() => {
+ sectionRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }, [currentPage, isLoading]);
+
return (
-
+
{categoryData.map(category => {
@@ -76,7 +108,7 @@ export default function TabsSection() {
{
setCurrentPage(page);
updateUrl(active, page);
- window.scrollTo({ top: 0, behavior: 'smooth' });
},
[active, updateUrl]
);
diff --git a/frontend/components/quiz/CountdownTimer.tsx b/frontend/components/quiz/CountdownTimer.tsx
index 8d286c97..ea13bede 100644
--- a/frontend/components/quiz/CountdownTimer.tsx
+++ b/frontend/components/quiz/CountdownTimer.tsx
@@ -1,6 +1,6 @@
'use client';
-import { TriangleAlert } from 'lucide-react';
+import { Clock, TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
@@ -21,19 +21,32 @@ export function CountdownTimer({
}: CountdownTimerProps) {
const t = useTranslations('quiz.timer');
const endTime = startedAt.getTime() + timeLimitSeconds * 1000;
- const [remainingSeconds, setRemainingSeconds] = useState(() =>
- Math.max(0, Math.floor((endTime - Date.now()) / 1000))
- );
+ const [remainingSeconds, setRemainingSeconds] = useState(timeLimitSeconds);
+ const [isSynced, setIsSynced] = useState(false);
+ const [prevEndTime, setPrevEndTime] = useState(endTime);
+
+ if (endTime !== prevEndTime) {
+ setPrevEndTime(endTime);
+ setIsSynced(false);
+ setRemainingSeconds(timeLimitSeconds);
+ }
useEffect(() => {
if (!isActive) return;
+ let synced = false;
+
const interval = setInterval(() => {
const now = Date.now();
const remaining = Math.max(0, Math.floor((endTime - now) / 1000));
setRemainingSeconds(remaining);
+ if (!synced) {
+ synced = true;
+ setIsSynced(true);
+ }
+
if (remaining === 0) {
clearInterval(interval);
queueMicrotask(onTimeUp);
@@ -48,6 +61,7 @@ export function CountdownTimer({
const handleVisibilityChange = () => {
if (!document.hidden) {
+ setIsSynced(false);
const remaining = Math.max(
0,
Math.floor((endTime - Date.now()) / 1000)
@@ -101,7 +115,8 @@ export function CountdownTimer({
) : (
<>
-
⏰ {t('hurryUp')}
+
{' '}
+ {t('hurryUp')}
>
)}
diff --git a/frontend/components/shared/Footer.tsx b/frontend/components/shared/Footer.tsx
index 7535f632..d4a71d83 100644
--- a/frontend/components/shared/Footer.tsx
+++ b/frontend/components/shared/Footer.tsx
@@ -3,6 +3,7 @@
import { Github, Linkedin, Send } from 'lucide-react';
import { useSelectedLayoutSegments } from 'next/navigation';
import { useTranslations } from 'next-intl';
+import type { Ref } from 'react';
import { ThemeToggle } from '@/components/theme/ThemeToggle';
import { Link } from '@/i18n/routing';
@@ -18,12 +19,17 @@ const SOCIAL = [
{ label: 'Telegram', href: 'https://t.me/devloversteam', Icon: Send },
] as const;
-export default function Footer() {
+export default function Footer({
+ footerRef,
+}: {
+ footerRef?: Ref
;
+}) {
const t = useTranslations('footer');
const segments = useSelectedLayoutSegments();
const isShop = segments.includes('shop');
return (