diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b5e887..bc2580b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -510,3 +510,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Reduced database load for quiz pages via Redis caching - Improved frontend loading experience during navigation + +## [1.0.0] - 2026-02-14 + +### Added + +- Complete production-ready DevLovers platform: + - Interactive Home with redesigned Hero and Features sections + - Full Quiz learning cycle: play, results, review, and dashboard history + - Quiz integrity system with violations counter and status badges + - Breadcrumb navigation for quiz review flow +- Dashboard improvements: + - Quiz Results history with scores, percentages, and mastery status + - User avatars with automatic fallback generation +- Q&A improvements: + - Next.js category enhancements and optimized loader behavior + - Configurable pagination size (10–100 items) with URL persistence +- Leaderboard enhancements: + - User avatars in podium and table rows + - Improved layout consistency across devices +- Platform transparency: + - Public `/humans.txt` with team and project information +- Observability & monitoring: + - Full Sentry integration for production error and performance tracking +- CI & supply-chain security: + - Safe-chain dependency protection in GitHub Actions +- Shop & payments: + - Monobank acquiring flow (UAH-only, feature-gated) + - Webhook verification, idempotent processing, and admin operations + - Internal Monobank janitor jobs for payment state consistency + +### Changed + +- Home page redesigned with improved UX, animations, and full localization +- Header and navigation refined with contextual behavior and loading states +- Quiz UX improvements: + - Unified result thresholds (Study / Review / Mastered) + - Locale switch now preserves result screen + - Auto-scroll to Next action after answering +- Mobile experience significantly improved across: + - Dashboard + - Leaderboard + - AI Word Helper +- SEO & social sharing: + - Localized Open Graph and Twitter metadata + - MetadataBase configuration for correct preview URLs +- Product experience: + - Multi-language product descriptions + - Improved rendering and formatting + +### Fixed + +- Points mismatch between dashboard and leaderboard +- Locale redirect issues for dashboard and quiz result pages +- Review cache isolation to prevent cross-user data leakage +- Quiz result locale switch losing state +- Multiple Tailwind v4 canonical class warnings +- Various mobile layout and interaction issues + +### Security + +- CI dependency scanning with Safe-chain +- OAuth and environment configuration stabilization +- Internal operational endpoints protected with secret-based access + +### Infrastructure + +- Redis caching for quiz questions and review data +- Environment configuration cleanup and standardization +- Improved build stability and dependency management diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx index be15de5f..84effb5b 100644 --- a/frontend/app/[locale]/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -3,6 +3,7 @@ import { getTranslations } from 'next-intl/server'; import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync'; import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard'; import { ProfileCard } from '@/components/dashboard/ProfileCard'; +import { QuizResultsSection } from '@/components/dashboard/QuizResultsSection'; import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner'; import { StatsCard } from '@/components/dashboard/StatsCard'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; @@ -64,8 +65,10 @@ export default async function DashboardPage({ : null; const userForDisplay = { + id: user.id, name: user.name ?? null, email: user.email ?? '', + image: user.image ?? null, role: user.role ?? null, points: user.points, createdAt: user.createdAt ?? null, @@ -114,6 +117,9 @@ export default async function DashboardPage({
+
+ +
diff --git a/frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx b/frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx index b880183f..4af8770e 100644 --- a/frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx +++ b/frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx @@ -1,15 +1,14 @@ -import Image from 'next/image'; -import { categoryTabStyles } from '@/data/categoryStyles'; - import { ArrowLeft, CheckCircle, RotateCcw, SearchX } from 'lucide-react'; +import Image from 'next/image'; import { getTranslations } from 'next-intl/server'; -import { cn } from '@/lib/utils'; import { QuizReviewList } from '@/components/dashboard/QuizReviewList'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; +import { categoryTabStyles } from '@/data/categoryStyles'; import { getAttemptReviewDetails } from '@/db/queries/quiz'; import { Link, redirect } from '@/i18n/routing'; import { getCurrentUser } from '@/lib/auth'; +import { cn } from '@/lib/utils'; export async function generateMetadata({ params, @@ -38,6 +37,7 @@ export default async function QuizReviewPage({ } const t = await getTranslations('dashboard.quizReview'); + const tNav = await getTranslations('navigation'); const review = await getAttemptReviewDetails(attemptId, session.id, locale); const cardStyles = @@ -109,6 +109,21 @@ export default async function QuizReviewPage({ return (
+
{categoryStyle && ( diff --git a/frontend/app/[locale]/page.tsx b/frontend/app/[locale]/page.tsx index e14bdcac..6c9abbfa 100644 --- a/frontend/app/[locale]/page.tsx +++ b/frontend/app/[locale]/page.tsx @@ -1,6 +1,9 @@ import { getTranslations } from 'next-intl/server'; -import HeroSection from '@/components/home/HeroSection'; +import FeaturesHeroSection from '@/components/home/FeaturesHeroSection'; +import HomePageScroll from '@/components/home/HomePageScroll'; +import WelcomeHeroSection from '@/components/home/WelcomeHeroSection'; +import Footer from '@/components/shared/Footer'; export async function generateMetadata({ params, @@ -59,8 +62,20 @@ export async function generateMetadata({ export default function Home() { return ( - <> - - + +
+ +
+
+ +
+
+ ); } diff --git a/frontend/app/[locale]/quiz/[slug]/page.tsx b/frontend/app/[locale]/quiz/[slug]/page.tsx index 47145586..5e3305f1 100644 --- a/frontend/app/[locale]/quiz/[slug]/page.tsx +++ b/frontend/app/[locale]/quiz/[slug]/page.tsx @@ -5,10 +5,10 @@ import { getTranslations } from 'next-intl/server'; import { QuizContainer } from '@/components/quiz/QuizContainer'; import { categoryTabStyles } from '@/data/categoryStyles'; -import { cn } from '@/lib/utils'; import { stripCorrectAnswers } from '@/db/queries/quiz'; import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz'; import { getCurrentUser } from '@/lib/auth'; +import { cn } from '@/lib/utils'; type MetadataProps = { params: Promise<{ locale: string; slug: string }> }; @@ -52,9 +52,10 @@ export default async function QuizPage({ notFound(); } - const categoryStyle = quiz.categorySlug - ? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles] - : null; + const categoryStyle = + quiz.categorySlug && quiz.categorySlug in categoryTabStyles + ? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles] + : null; const parsedSeed = seedParam ? Number.parseInt(seedParam, 10) : Number.NaN; const seed = Number.isFinite(parsedSeed) diff --git a/frontend/app/[locale]/shop/products/[slug]/page.tsx b/frontend/app/[locale]/shop/products/[slug]/page.tsx index 5b83cc12..fab252c9 100644 --- a/frontend/app/[locale]/shop/products/[slug]/page.tsx +++ b/frontend/app/[locale]/shop/products/[slug]/page.tsx @@ -2,7 +2,7 @@ import { ArrowLeft } from 'lucide-react'; import { Metadata } from 'next'; import Image from 'next/image'; import { notFound } from 'next/navigation'; -import { getTranslations } from 'next-intl/server'; +import { getMessages, getTranslations } from 'next-intl/server'; import { AddToCartButton } from '@/components/shop/AddToCartButton'; import { getPublicProductBySlug } from '@/db/queries/shop/products'; @@ -54,6 +54,9 @@ export default async function ProductPage({ 'text-lg', 'items-center gap-2' ); + const messages = await getMessages(); + const productDescriptions = + (messages as any).shop?.productDescriptions ?? {}; const badge = product?.badge as string | undefined; const badgeLabel = badge && badge !== 'NONE' @@ -131,9 +134,18 @@ export default async function ProductPage({ )} - {product.description && ( -

{product.description}

- )} + {(() => { + const desc = + (productDescriptions[slug] as string) || product.description; + if (!desc) return null; + return ( +
+ {desc.split('\n').map((line: string, i: number) => ( +

{line}

+ ))} +
+ ); + })()} {!isUnavailable && (
diff --git a/frontend/app/api/questions/[category]/route.ts b/frontend/app/api/questions/[category]/route.ts index 76470c04..2e82a697 100644 --- a/frontend/app/api/questions/[category]/route.ts +++ b/frontend/app/api/questions/[category]/route.ts @@ -37,7 +37,7 @@ export async function GET( const page = Math.max(1, Number(searchParams.get('page') ?? DEFAULT_PAGE)); const limit = Math.min( - 50, + 100, Math.max(1, Number(searchParams.get('limit') ?? DEFAULT_LIMIT)) ); const offset = (page - 1) * limit; diff --git a/frontend/app/api/shop/internal/monobank/janitor/route.ts b/frontend/app/api/shop/internal/monobank/janitor/route.ts new file mode 100644 index 00000000..38eaf80a --- /dev/null +++ b/frontend/app/api/shop/internal/monobank/janitor/route.ts @@ -0,0 +1,466 @@ +import crypto from 'node:crypto'; + +import { sql } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { db } from '@/db'; +import { logError, logInfo, logWarn } from '@/lib/logging'; +import { guardNonBrowserOnly } from '@/lib/security/origin'; +import { + runMonobankJanitorJob1, + runMonobankJanitorJob2, + runMonobankJanitorJob3, + runMonobankJanitorJob4, +} from '@/lib/services/orders/monobank-janitor'; + +const ROUTE_PATH = '/api/shop/internal/monobank/janitor' as const; +const JOB_PREFIX = 'monobank-janitor:' as const; +const JOB_NAMES = ['job1', 'job2', 'job3', 'job4'] as const; +const JOB_NAME_SET = new Set(JOB_NAMES); + +type JobName = (typeof JOB_NAMES)[number]; + +const JOB_NAME_RE = /^[a-z0-9-]{1,32}$/; + +const janitorPayloadSchema = z + .object({ + job: z + .string() + .regex(JOB_NAME_RE) + .refine(value => JOB_NAME_SET.has(value), { message: 'Invalid job' }), + dryRun: z.boolean().optional(), + limit: z.number().int().optional(), + }) + .strict(); + +type GateRow = { next_allowed_at: unknown }; + +function noStoreJson( + body: unknown, + requestId: string, + init?: { status?: number; headers?: HeadersInit } +) { + const res = NextResponse.json(body, { + status: init?.status ?? 200, + headers: init?.headers, + }); + res.headers.set('Cache-Control', 'no-store'); + res.headers.set('X-Request-Id', requestId); + return res; +} + +function timingSafeEqual(a: string, b: string): boolean { + const aBuf = Buffer.from(a, 'utf8'); + const bBuf = Buffer.from(b, 'utf8'); + const maxLen = Math.max(aBuf.length, bBuf.length, 1); + + const aPadded = Buffer.alloc(maxLen); + const bPadded = Buffer.alloc(maxLen); + aBuf.copy(aPadded); + bBuf.copy(bPadded); + + const equalPadded = crypto.timingSafeEqual(aPadded, bPadded); + return equalPadded && aBuf.length === bBuf.length; +} + +function readConfiguredInternalSecret(): string | null { + const preferred = (process.env.INTERNAL_JANITOR_SECRET ?? '').trim(); + if (preferred) return preferred; + + const fallback = (process.env.INTERNAL_SECRET ?? '').trim(); + if (fallback) return fallback; + + return null; +} + +function requireInternalJanitorAuth(args: { + request: NextRequest; + requestId: string; + baseMeta: Record; +}): NextResponse | null { + const configured = readConfiguredInternalSecret(); + if (!configured) { + logError('internal_monobank_janitor_auth_misconfigured', undefined, { + ...args.baseMeta, + code: 'INTERNAL_SECRET_MISCONFIG', + }); + return noStoreJson( + { + success: false, + code: 'SERVER_MISCONFIG', + message: 'Internal auth is not configured', + requestId: args.requestId, + }, + args.requestId, + { status: 500 } + ); + } + + const provided = + (args.request.headers.get('x-internal-janitor-secret') ?? '').trim() || + (args.request.headers.get('x-internal-secret') ?? '').trim(); + + if (!provided || !timingSafeEqual(provided, configured)) { + return noStoreJson( + { + success: false, + code: 'UNAUTHORIZED', + message: 'Unauthorized', + requestId: args.requestId, + }, + args.requestId, + { status: 401 } + ); + } + + return null; +} + +function invalidPayload( + requestId: string, + message: string +): NextResponse { + return noStoreJson( + { + success: false, + code: 'INVALID_PAYLOAD', + message, + requestId, + }, + requestId, + { status: 400 } + ); +} + +function normalizeDate(x: unknown): Date | null { + if (!x) return null; + if (x instanceof Date) return Number.isNaN(x.getTime()) ? null : x; + const d = new Date(String(x)); + return Number.isNaN(d.getTime()) ? null : d; +} + +function getMinIntervalSeconds(): number { + if (process.env.NODE_ENV === 'test') return 0; + + const fallback = process.env.NODE_ENV === 'production' ? 300 : 60; + const parsed = Number(process.env.INTERNAL_JANITOR_MIN_INTERVAL_SECONDS); + const base = Number.isFinite(parsed) ? Math.floor(parsed) : fallback; + return Math.max(0, Math.min(3600, base)); +} + +async function acquireJobSlot(params: { + jobName: string; + minIntervalSeconds: number; + runId: string; +}) { + const res = await db.execute(sql` + INSERT INTO internal_job_state (job_name, next_allowed_at, last_run_id, updated_at) + VALUES ( + ${params.jobName}, + now() + make_interval(secs => ${params.minIntervalSeconds}), + ${params.runId}::uuid, + now() + ) + ON CONFLICT (job_name) DO UPDATE + SET next_allowed_at = now() + make_interval(secs => ${params.minIntervalSeconds}), + last_run_id = ${params.runId}::uuid, + updated_at = now() + WHERE internal_job_state.next_allowed_at <= now() + RETURNING next_allowed_at + `); + + const rows = (res as any).rows ?? []; + if (rows.length > 0) return { ok: true as const }; + + const res2 = await db.execute(sql` + SELECT next_allowed_at + FROM internal_job_state + WHERE job_name = ${params.jobName} + LIMIT 1 + `); + + const rows2 = (res2 as any).rows ?? []; + const nextAllowedAt = normalizeDate(rows2[0]?.next_allowed_at); + return { ok: false as const, nextAllowedAt }; +} + +function parsePayloadErrorMessage(error: z.ZodError): string { + for (const issue of error.issues) { + if (issue.path[0] === 'job') return 'Invalid job'; + } + return 'Invalid payload'; +} + +export async function POST(request: NextRequest) { + const requestId = + request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const baseMeta = { + requestId, + route: ROUTE_PATH, + method: 'POST', + jobName: 'monobank-janitor', + }; + + const blocked = guardNonBrowserOnly(request); + if (blocked) { + blocked.headers.set('X-Request-Id', requestId); + logWarn('internal_monobank_janitor_origin_blocked', { + ...baseMeta, + code: 'ORIGIN_BLOCKED', + }); + return blocked; + } + + const authResponse = requireInternalJanitorAuth({ + request, + requestId, + baseMeta, + }); + if (authResponse) { + if (authResponse.status === 401) { + logWarn('internal_monobank_janitor_auth_rejected', { + ...baseMeta, + code: 'UNAUTHORIZED', + }); + } + return authResponse; + } + + const contentType = (request.headers.get('content-type') ?? '').toLowerCase(); + if (!contentType.includes('application/json')) { + return invalidPayload(requestId, 'Content-Type must be application/json'); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return invalidPayload(requestId, 'Invalid JSON body'); + } + + const parsed = janitorPayloadSchema.safeParse(rawBody); + if (!parsed.success) { + return invalidPayload(requestId, parsePayloadErrorMessage(parsed.error)); + } + + const job = parsed.data.job as JobName; + const dryRun = parsed.data.dryRun ?? false; + const limit = Math.max(1, Math.min(500, parsed.data.limit ?? 100)); + + logInfo('internal_monobank_janitor_request_accepted', { + ...baseMeta, + code: 'JANITOR_REQUEST_ACCEPTED', + job, + dryRun, + limit, + }); + + const runId = crypto.randomUUID(); + const gateKey = `${JOB_PREFIX}${job}`; + const minIntervalSeconds = getMinIntervalSeconds(); + + try { + const gate = await acquireJobSlot({ + jobName: gateKey, + minIntervalSeconds, + runId, + }); + + if (!gate.ok) { + const retryAfterSeconds = gate.nextAllowedAt + ? Math.max( + 1, + Math.ceil((gate.nextAllowedAt.getTime() - Date.now()) / 1000) + ) + : Math.max(1, minIntervalSeconds || 1); + + logWarn('internal_monobank_janitor_rate_limited', { + ...baseMeta, + code: 'JANITOR_RATE_LIMITED', + job, + dryRun, + limit, + gateKey, + runId, + retryAfterSeconds, + }); + + return noStoreJson( + { + success: false, + code: 'RATE_LIMITED', + retryAfterSeconds, + requestId, + }, + requestId, + { + status: 429, + headers: { 'Retry-After': String(retryAfterSeconds) }, + } + ); + } + + if (job === 'job1') { + const result = await runMonobankJanitorJob1({ + dryRun, + limit, + requestId, + runId, + baseMeta, + }); + + return noStoreJson( + { + success: true, + job, + dryRun, + limit, + processed: result.processed, + applied: result.applied, + noop: result.noop, + failed: result.failed, + requestId, + }, + requestId, + { status: 200 } + ); + } + + if (job === 'job2') { + const result = await runMonobankJanitorJob2({ + dryRun, + limit, + requestId, + runId, + baseMeta, + }); + + return noStoreJson( + { + success: true, + job, + dryRun, + limit, + processed: result.processed, + applied: result.applied, + noop: result.noop, + failed: result.failed, + requestId, + }, + requestId, + { status: 200 } + ); + } + + if (job === 'job3') { + const result = await runMonobankJanitorJob3({ + dryRun, + limit, + requestId, + runId, + baseMeta, + }); + + return noStoreJson( + { + success: true, + job, + dryRun, + limit, + processed: result.processed, + applied: result.applied, + noop: result.noop, + failed: result.failed, + requestId, + }, + requestId, + { status: 200 } + ); + } + + if (job === 'job4') { + const result = await runMonobankJanitorJob4({ + dryRun, + limit, + requestId, + runId, + baseMeta, + }); + + return noStoreJson( + { + success: true, + job, + dryRun, + limit, + processed: result.processed, + applied: result.applied, + noop: result.noop, + failed: result.failed, + report: result.report, + requestId, + }, + requestId, + { status: 200 } + ); + } + + logInfo('internal_monobank_janitor_not_implemented', { + ...baseMeta, + code: 'JANITOR_NOT_IMPLEMENTED', + job, + dryRun, + limit, + gateKey, + runId, + }); + + return noStoreJson( + { + success: false, + code: 'JANITOR_NOT_IMPLEMENTED', + job, + dryRun, + limit, + requestId, + }, + requestId, + { status: 501 } + ); + } catch (error) { + const err = error as { code?: unknown; status?: unknown } | null; + if (err?.code === 'MONO_WEBHOOK_MODE_NOT_STORE') { + return noStoreJson( + { + success: false, + code: 'MONO_WEBHOOK_MODE_NOT_STORE', + requestId, + }, + requestId, + { status: 409 } + ); + } + + logError('internal_monobank_janitor_failed', error, { + ...baseMeta, + code: 'INTERNAL_ERROR', + job, + dryRun, + limit, + gateKey, + runId, + }); + + return noStoreJson( + { + success: false, + code: 'INTERNAL_ERROR', + message: 'Internal error', + requestId, + }, + requestId, + { status: 500 } + ); + } +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7ab59b5f..7d97ed10 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -277,6 +277,19 @@ .animate-dash-flow { animation: dash-flow 2s linear infinite; } + + /* 3D Flip Card */ + .perspective-1000 { + perspective: 1000px; + } + + .preserve-3d { + transform-style: preserve-3d; + } + + .backface-hidden { + backface-visibility: hidden; + } } @keyframes float { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 529bb7c0..3028e0b4 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,10 +1,9 @@ import './globals.css'; -import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; import { Analytics } from '@vercel/analytics/next'; import { SpeedInsights } from '@vercel/speed-insights/next'; -import './globals.css'; +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://devlovers.net'; @@ -51,13 +50,13 @@ export const metadata: Metadata = { }; const geistSans = Geist({ + subsets: ['latin', 'cyrillic'], variable: '--font-geist-sans', - subsets: ['latin'], }); const geistMono = Geist_Mono({ + subsets: ['latin', 'cyrillic'], variable: '--font-geist-mono', - subsets: ['latin'], }); export default function RootLayout({ diff --git a/frontend/components/about/HeroSection.tsx b/frontend/components/about/HeroSection.tsx index df3487df..29f3f263 100644 --- a/frontend/components/about/HeroSection.tsx +++ b/frontend/components/about/HeroSection.tsx @@ -16,7 +16,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) { questionsSolved: '850+', githubStars: '120+', activeUsers: '200+', - linkedinFollowers: '1.3k+', + linkedinFollowers: '1.5k+', }; return ( diff --git a/frontend/components/dashboard/ExplainedTermsCard.tsx b/frontend/components/dashboard/ExplainedTermsCard.tsx index 6cb480b3..a9e10ef8 100644 --- a/frontend/components/dashboard/ExplainedTermsCard.tsx +++ b/frontend/components/dashboard/ExplainedTermsCard.tsx @@ -2,7 +2,7 @@ import { BookOpen, ChevronDown, GripVertical, RotateCcw, X } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import AIWordHelper from '@/components/q&a/AIWordHelper'; import { getCachedTerms } from '@/lib/ai/explainCache'; @@ -21,6 +21,13 @@ export function ExplainedTermsCard() { const [selectedTerm, setSelectedTerm] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); + const [touchDragState, setTouchDragState] = useState<{ + sourceIndex: number; + targetIndex: number; + x: number; + y: number; + label: string; + } | null>(null); /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { @@ -83,6 +90,131 @@ export function ExplainedTermsCard() { setDraggedIndex(null); }; + // Touch drag support for mobile + const touchDragIndex = useRef(null); + const termRefs = useRef>(new Map()); + const dragTargetIndex = useRef(null); + const cleanupRef = useRef<(() => void) | null>(null); + + const setTermRef = useCallback( + (index: number) => (el: HTMLDivElement | null) => { + if (el) { + termRefs.current.set(index, el); + } else { + termRefs.current.delete(index); + } + }, + [] + ); + + const setTouchDragStateRef = useRef(setTouchDragState); + useEffect(() => { + setTouchDragStateRef.current = setTouchDragState; + }, [setTouchDragState]); + + const termsRef = useRef(terms); + useEffect(() => { + termsRef.current = terms; + }, [terms]); + + const handleTouchStart = useCallback( + (index: number, e: React.TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + touchDragIndex.current = index; + dragTargetIndex.current = index; + setDraggedIndex(index); + setTouchDragStateRef.current({ + sourceIndex: index, + targetIndex: index, + x: touch.clientX, + y: touch.clientY, + label: termsRef.current[index] ?? '', + }); + }, + [] + ); + + const containerCallbackRef = useCallback((node: HTMLDivElement | null) => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + + if (!node) return; + + const onTouchMove = (e: TouchEvent) => { + if (touchDragIndex.current === null) return; + e.preventDefault(); + + const touch = e.touches[0]; + if (!touch) return; + + let newTarget = dragTargetIndex.current; + + for (const [index, el] of termRefs.current.entries()) { + const rect = el.getBoundingClientRect(); + if ( + touch.clientX >= rect.left && + touch.clientX <= rect.right && + touch.clientY >= rect.top && + touch.clientY <= rect.bottom && + index !== touchDragIndex.current + ) { + newTarget = index; + break; + } + } + + dragTargetIndex.current = newTarget; + setDraggedIndex(newTarget); + setTouchDragStateRef.current(prev => + prev + ? { + ...prev, + targetIndex: newTarget ?? prev.targetIndex, + x: touch.clientX, + y: touch.clientY, + } + : null + ); + }; + + const onTouchEnd = () => { + const fromIndex = touchDragIndex.current; + const toIndex = dragTargetIndex.current; + + if ( + fromIndex !== null && + toIndex !== null && + fromIndex !== toIndex + ) { + setTerms(prevTerms => { + const newTerms = [...prevTerms]; + const [dragged] = newTerms.splice(fromIndex, 1); + newTerms.splice(toIndex, 0, dragged); + saveTermOrder(newTerms); + return newTerms; + }); + } + + touchDragIndex.current = null; + dragTargetIndex.current = null; + setDraggedIndex(null); + setTouchDragStateRef.current(null); + }; + + node.addEventListener('touchmove', onTouchMove, { passive: false }); + node.addEventListener('touchend', onTouchEnd); + node.addEventListener('touchcancel', onTouchEnd); + + cleanupRef.current = () => { + node.removeEventListener('touchmove', onTouchMove); + node.removeEventListener('touchend', onTouchEnd); + node.removeEventListener('touchcancel', onTouchEnd); + }; + }, []); + const handleTermClick = (term: string) => { setSelectedTerm(term); setIsModalOpen(true); @@ -132,19 +264,39 @@ export function ExplainedTermsCard() {

{t('termCount', { count: terms.length })}

-
- {terms.map((term, index) => ( +
+ {terms.map((term, index) => { + const isSource = + touchDragState !== null && + index === touchDragState.sourceIndex; + const isDropTarget = + touchDragState !== null && + index === touchDragState.targetIndex && + index !== touchDragState.sourceIndex; + + return (
handleDrop(index)} className={`group relative inline-flex items-center gap-1 rounded-lg border px-2 py-2 pr-8 transition-all ${ - draggedIndex === index ? 'opacity-50' : '' + isSource + ? 'scale-95 opacity-40' + : isDropTarget + ? 'border-(--accent-primary) bg-(--accent-primary)/10 scale-105' + : draggedIndex === index + ? 'opacity-50' + : '' } border-gray-100 bg-gray-50/50 hover:border-(--accent-primary)/30 hover:bg-white dark:border-white/5 dark:bg-neutral-800/50 dark:hover:border-(--accent-primary)/30 dark:hover:bg-neutral-800`} >
- ))} + ); + })}
) : ( @@ -220,7 +373,7 @@ export function ExplainedTermsCard() { handleRestoreTerm(term); }} aria-label={t('ariaRestore', { term })} - className="absolute -right-1 -top-1 rounded-full bg-white p-1 text-gray-400 opacity-0 shadow-sm transition-opacity hover:bg-green-50 hover:text-green-600 group-hover:opacity-100 dark:bg-neutral-800 dark:hover:bg-green-900/20 dark:hover:text-green-400" + className="absolute -right-1 -top-1 rounded-full bg-white p-1 text-gray-400 opacity-100 shadow-sm transition-opacity hover:bg-green-50 hover:text-green-600 sm:opacity-0 sm:group-hover:opacity-100 dark:bg-neutral-800 dark:hover:bg-green-900/20 dark:hover:text-green-400" > @@ -245,6 +398,20 @@ export function ExplainedTermsCard() { onClose={handleModalClose} /> )} + + {touchDragState && ( +
+ + {touchDragState.label} +
+ )} ); } diff --git a/frontend/components/dashboard/ProfileCard.tsx b/frontend/components/dashboard/ProfileCard.tsx index a3f77fe1..601db04e 100644 --- a/frontend/components/dashboard/ProfileCard.tsx +++ b/frontend/components/dashboard/ProfileCard.tsx @@ -2,10 +2,14 @@ import { useTranslations } from 'next-intl'; +import { UserAvatar } from '@/components/leaderboard/UserAvatar'; + interface ProfileCardProps { user: { + id: string; name: string | null; email: string; + image: string | null; role: string | null; points: number; createdAt: Date | null; @@ -15,6 +19,11 @@ interface ProfileCardProps { export function ProfileCard({ user, locale }: ProfileCardProps) { const t = useTranslations('dashboard.profile'); + const username = user.name || user.email.split('@')[0]; + const seed = `${username}-${user.id}`; + const avatarSrc = + user.image || + `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(seed)}`; const cardStyles = ` relative overflow-hidden rounded-2xl @@ -27,11 +36,15 @@ export function ProfileCard({ user, locale }: ProfileCardProps) {
-
+
{hasResults ? ( ) : ( diff --git a/frontend/components/leaderboard/LeaderboardTable.tsx b/frontend/components/leaderboard/LeaderboardTable.tsx index 5b580a57..ee18cb77 100644 --- a/frontend/components/leaderboard/LeaderboardTable.tsx +++ b/frontend/components/leaderboard/LeaderboardTable.tsx @@ -39,6 +39,11 @@ export function LeaderboardTable({
+ + + + + @@ -83,6 +88,11 @@ export function LeaderboardTable({
{t('tableCaption')}
+ + + + + diff --git a/frontend/components/q&a/AIWordHelper.tsx b/frontend/components/q&a/AIWordHelper.tsx index 2f2e35b0..7beb3c70 100644 --- a/frontend/components/q&a/AIWordHelper.tsx +++ b/frontend/components/q&a/AIWordHelper.tsx @@ -336,6 +336,21 @@ export default function AIWordHelper({ [position] ); + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + setDragState({ + isDragging: true, + startX: touch.clientX, + startY: touch.clientY, + offsetX: position.x, + offsetY: position.y, + }); + }, + [position] + ); + useEffect(() => { if (!dragState.isDragging) return; @@ -348,16 +363,34 @@ export default function AIWordHelper({ }); }; - const handleMouseUp = () => { + const handleTouchMove = (e: TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + e.preventDefault(); + const deltaX = touch.clientX - dragState.startX; + const deltaY = touch.clientY - dragState.startY; + setPosition({ + x: dragState.offsetX + deltaX, + y: dragState.offsetY + deltaY, + }); + }; + + const handleDragEnd = () => { setDragState(prev => ({ ...prev, isDragging: false })); }; document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('mouseup', handleDragEnd); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleDragEnd); + document.addEventListener('touchcancel', handleDragEnd); return () => { document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('mouseup', handleDragEnd); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleDragEnd); + document.removeEventListener('touchcancel', handleDragEnd); }; }, [dragState]); @@ -447,9 +480,10 @@ export default function AIWordHelper({ className={cn( 'flex items-center justify-between border-b border-gray-200 p-4 dark:border-neutral-800', 'cursor-grab active:cursor-grabbing', - 'select-none' + 'select-none touch-none' )} onMouseDown={handleDragStart} + onTouchStart={handleTouchStart} >
@@ -463,6 +497,7 @@ export default function AIWordHelper({ - -
- {pages.map((page, index) => - page === 'ellipsis' ? ( - onPageChange(currentPage - 1)} + disabled={currentPage <= 1} + className={cn( + 'rounded-lg px-2 py-2 text-sm font-medium transition-colors sm:px-3', + 'border border-gray-300 bg-white/90 dark:border-gray-700 dark:bg-neutral-900/80', + currentPage === 1 + ? 'cursor-not-allowed text-gray-400 dark:text-gray-600' + : 'text-gray-700 hover:bg-[var(--qa-accent-soft)] dark:text-gray-300' + )} + aria-label={t('previousPage')} + > + ← {t('previous')} + + +
+ {pages.map((page, index) => + page === 'ellipsis' ? ( + + ... + + ) : ( + + ) + )} +
+ + + + + {onPageSizeChange && pageSizeOptions.length > 1 && ( +
+ +
+ + +
+
+ )} +
); } diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx index d4b8d9bc..434d7da4 100644 --- a/frontend/components/q&a/QaSection.tsx +++ b/frontend/components/q&a/QaSection.tsx @@ -24,9 +24,12 @@ export default function TabsSection() { currentPage, handleCategoryChange, handlePageChange, + handlePageSizeChange, isLoading, items, localeKey, + pageSize, + pageSizeOptions, totalPages, } = useQaTabs(); const animationKey = useMemo( @@ -123,11 +126,14 @@ export default function TabsSection() { ))} - {!isLoading && totalPages > 1 && ( + {!isLoading && items.length > 0 && ( category.slug); const DEFAULT_CATEGORY = CATEGORY_SLUGS[0] || 'html'; +const PAGE_SIZE_OPTIONS = [10, 20, 40, 60, 80, 100] as const; +const DEFAULT_PAGE_SIZE = PAGE_SIZE_OPTIONS[0]; +type QaPageSize = (typeof PAGE_SIZE_OPTIONS)[number]; function isCategorySlug(value: string): value is CategorySlug { return CATEGORY_SLUGS.includes(value); } +function isQaPageSize(value: number): value is QaPageSize { + return PAGE_SIZE_OPTIONS.includes(value as QaPageSize); +} + export function useQaTabs() { const router = useRouter(); const searchParams = useSearchParams(); @@ -30,21 +37,28 @@ export function useQaTabs() { const pageFromUrl = rawPage ? Number(rawPage) : 1; const safePageFromUrl = Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? pageFromUrl : 1; + const rawSize = searchParams.get('size'); + const parsedSize = rawSize ? Number(rawSize) : DEFAULT_PAGE_SIZE; + const safePageSizeFromUrl = isQaPageSize(parsedSize) + ? parsedSize + : DEFAULT_PAGE_SIZE; const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY; const [active, setActive] = useState( isCategorySlug(categoryFromUrl) ? categoryFromUrl : DEFAULT_CATEGORY ); const [currentPage, setCurrentPage] = useState(safePageFromUrl); + const [pageSize, setPageSize] = useState(safePageSizeFromUrl); const [items, setItems] = useState([]); const [totalPages, setTotalPages] = useState(0); const [isLoading, setIsLoading] = useState(true); const updateUrl = useCallback( - (category: CategorySlug, page: number) => { + (category: CategorySlug, page: number, size: QaPageSize) => { const params = new URLSearchParams(); if (category !== DEFAULT_CATEGORY) params.set('category', category); if (page > 1) params.set('page', String(page)); + if (size !== DEFAULT_PAGE_SIZE) params.set('size', String(size)); const queryString = params.toString(); @@ -59,6 +73,10 @@ export function useQaTabs() { setCurrentPage(safePageFromUrl); }, [safePageFromUrl]); + useEffect(() => { + setPageSize(safePageSizeFromUrl); + }, [safePageSizeFromUrl]); + useEffect(() => { if (!isCategorySlug(categoryFromUrl)) { return; @@ -75,7 +93,7 @@ export function useQaTabs() { try { const res = await fetch( - `/api/questions/${active}?page=${currentPage}&limit=10&locale=${localeKey}`, + `/api/questions/${active}?page=${currentPage}&limit=${pageSize}&locale=${localeKey}`, { signal: controller.signal } ); @@ -113,7 +131,7 @@ export function useQaTabs() { isActive = false; controller.abort(); }; - }, [active, currentPage, localeKey]); + }, [active, currentPage, localeKey, pageSize]); const handleCategoryChange = useCallback( (category: string) => { @@ -122,15 +140,28 @@ export function useQaTabs() { } setActive(category); setCurrentPage(1); - updateUrl(category, 1); + updateUrl(category, 1, pageSize); }, - [updateUrl] + [pageSize, updateUrl] ); const handlePageChange = useCallback( (page: number) => { setCurrentPage(page); - updateUrl(active, page); + updateUrl(active, page, pageSize); + }, + [active, pageSize, updateUrl] + ); + + const handlePageSizeChange = useCallback( + (size: number) => { + if (!isQaPageSize(size)) { + return; + } + + setPageSize(size); + setCurrentPage(1); + updateUrl(active, 1, size); }, [active, updateUrl] ); @@ -140,9 +171,12 @@ export function useQaTabs() { currentPage, handleCategoryChange, handlePageChange, + handlePageSizeChange, isLoading, items, localeKey, + pageSize, + pageSizeOptions: PAGE_SIZE_OPTIONS, totalPages, }; } diff --git a/frontend/components/quiz/QuizCard.tsx b/frontend/components/quiz/QuizCard.tsx index d515abc7..610410c1 100644 --- a/frontend/components/quiz/QuizCard.tsx +++ b/frontend/components/quiz/QuizCard.tsx @@ -44,6 +44,18 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) { ? Math.round((userProgress.bestScore / userProgress.totalQuestions) * 100) : 0; + const getStatusBadge = () => { + if (!userProgress) return null; + if (percentage === 100) + return { variant: 'success' as const, label: t('mastered'), dot: 'bg-emerald-500' }; + if (percentage >= 70) + return { variant: 'warning' as const, label: t('needsReview'), dot: 'bg-amber-500' }; + return { variant: 'danger' as const, label: t('study'), dot: 'bg-red-500' }; + }; + + const statusBadge = getStatusBadge(); + + const handleStart = () => { const seed = makeSeed(); // runs on click, not render router.push(`/quiz/${quiz.slug}?seed=${seed}`); @@ -69,7 +81,12 @@ export function QuizCard({ quiz, userProgress }: QuizCardProps) { > {quiz.categoryName ?? t('uncategorized')} - {userProgress && {t('completed')}} + {statusBadge && ( + + + {statusBadge.label} + + )}

{quiz.title ?? quiz.slug} diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx index 6a2398ba..13dcab1e 100644 --- a/frontend/components/quiz/QuizContainer.tsx +++ b/frontend/components/quiz/QuizContainer.tsx @@ -1,4 +1,5 @@ 'use client'; +import { ViolationsCounter } from '@/components/quiz/ViolationsCounter'; import { Ban, FileText, TriangleAlert, UserRound } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useLocale, useTranslations } from 'next-intl'; @@ -122,6 +123,9 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState { startedAt: action.payload.startedAt ? new Date(action.payload.startedAt) : null, + pointsAwarded: action.payload.pointsAwarded ?? null, + attemptId: action.payload.attemptId ?? null, + isIncomplete: action.payload.isIncomplete ?? false, }; case 'RESTART': @@ -319,9 +323,6 @@ export function QuizContainer({ const handleSubmit = () => { const isIncomplete = state.answers.length < totalQuestions; - if (!isGuest) { - clearQuizSession(quizId); - } const correctAnswers = state.answers.filter(a => a.isCorrect).length; const percentage = (correctAnswers / totalQuestions) * 100; const timeSpentSeconds = state.startedAt @@ -402,6 +403,7 @@ export function QuizContainer({ }; const handleBackToTopicsClick = () => { + clearQuizSession(quizId); if (onBackToTopics) { onBackToTopics(); } else { @@ -543,7 +545,8 @@ export function QuizContainer({ return (
-
+
+

{motivation.message}

- {violationsCount >= 3 && ( + {violationsCount >= 4 && (

diff --git a/frontend/components/quiz/ViolationsCounter.tsx b/frontend/components/quiz/ViolationsCounter.tsx new file mode 100644 index 00000000..9491f6b1 --- /dev/null +++ b/frontend/components/quiz/ViolationsCounter.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { ShieldAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +import { cn } from '@/lib/utils'; + +interface ViolationsCounterProps { + count: number; +} + +export function ViolationsCounter({ count }: ViolationsCounterProps) { + const t = useTranslations('quiz.antiCheat'); + + const getColorClasses = () => { + if (count >= 4) { + return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800'; + } + if (count >= 1) { + return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950/30 border-yellow-200 dark:border-yellow-800'; + } + return 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800'; + }; + + return ( +
= 4 && 'animate-pulse' + )} + > +
+ ); +} diff --git a/frontend/components/shared/Footer.tsx b/frontend/components/shared/Footer.tsx index d4a71d83..b20d3378 100644 --- a/frontend/components/shared/Footer.tsx +++ b/frontend/components/shared/Footer.tsx @@ -1,11 +1,12 @@ 'use client'; import { Github, Linkedin, Send } from 'lucide-react'; -import { useSelectedLayoutSegments } from 'next/navigation'; +import { usePathname, useSelectedLayoutSegments } from 'next/navigation'; import { useTranslations } from 'next-intl'; import type { Ref } from 'react'; import { ThemeToggle } from '@/components/theme/ThemeToggle'; +import { locales } from '@/i18n/config'; import { Link } from '@/i18n/routing'; import { cn } from '@/lib/utils'; @@ -21,12 +22,27 @@ const SOCIAL = [ export default function Footer({ footerRef, + className, + forceVisible = false, }: { footerRef?: Ref; + className?: string; + forceVisible?: boolean; }) { const t = useTranslations('footer'); const segments = useSelectedLayoutSegments(); + const pathname = usePathname(); const isShop = segments.includes('shop'); + const pathSegments = pathname.split('/').filter(Boolean); + const isHome = + pathSegments.length === 0 || + (pathSegments.length === 1 && + locales.includes(pathSegments[0] as (typeof locales)[number])); + + if (isHome && !forceVisible) { + return null; + } + return (
diff --git a/frontend/components/shared/LanguageSwitcher.tsx b/frontend/components/shared/LanguageSwitcher.tsx index e78e0750..be20ae44 100644 --- a/frontend/components/shared/LanguageSwitcher.tsx +++ b/frontend/components/shared/LanguageSwitcher.tsx @@ -8,8 +8,8 @@ import { type Locale, locales } from '@/i18n/config'; import { Link } from '@/i18n/routing'; const localeLabels: Record = { - uk: 'UA', en: 'EN', + uk: 'UA', pl: 'PL', }; @@ -69,7 +69,7 @@ export default function LanguageSwitcher() { {isOpen && ( -
+
{locales.map(locale => ( (null); @@ -126,6 +127,12 @@ export function Loader({ className, size = 240 }: LoaderProps) { tick += 1; }; + for (let i = 0; i < STARTUP_WARMUP_FRAMES; i += 1) { + step(); + tick += 1; + } + draw(); + animationRef.current = requestAnimationFrame(loop); return () => { diff --git a/frontend/components/tests/q&a/pagination.test.tsx b/frontend/components/tests/q&a/pagination.test.tsx index f5587e37..865b83a3 100644 --- a/frontend/components/tests/q&a/pagination.test.tsx +++ b/frontend/components/tests/q&a/pagination.test.tsx @@ -126,4 +126,26 @@ describe('Pagination', () => { expect(screen.getAllByLabelText(/page-/).length).toBe(4); }); + + it('calls onPageSizeChange when selecting a different size', () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + + render( + + ); + + const select = screen.getByLabelText('itemsPerPageAria'); + fireEvent.change(select, { target: { value: '40' } }); + + expect(onPageSizeChange).toHaveBeenCalledWith(40); + }); }); diff --git a/frontend/components/tests/q&a/use-qa-tabs.test.tsx b/frontend/components/tests/q&a/use-qa-tabs.test.tsx index 75793854..aa46a2d8 100644 --- a/frontend/components/tests/q&a/use-qa-tabs.test.tsx +++ b/frontend/components/tests/q&a/use-qa-tabs.test.tsx @@ -139,4 +139,27 @@ describe('useQaTabs', () => { expect(result.current.totalPages).toBe(0); consoleSpy.mockRestore(); }); + + it('updates page size and URL on size change', async () => { + const { result } = renderHook(() => useQaTabs()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.handlePageSizeChange(40); + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/questions/git?page=1&limit=40&locale=en', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + expect(routerReplace).toHaveBeenCalledWith('/q&a?size=40', { + scroll: false, + }); + }); }); diff --git a/frontend/components/ui/particle-canvas.tsx b/frontend/components/ui/particle-canvas.tsx index 27cd2ea2..15130e25 100644 --- a/frontend/components/ui/particle-canvas.tsx +++ b/frontend/components/ui/particle-canvas.tsx @@ -363,4 +363,4 @@ export function ParticleCanvas({ }, []); return