Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions frontend/app/[locale]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -114,6 +117,9 @@ export default async function DashboardPage({
<div className="mt-8">
<ExplainedTermsCard />
</div>
<div className="mt-8">
<QuizResultsSection attempts={lastAttempts} locale={locale} />
</div>
</main>
</DynamicGridBackground>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -109,6 +109,21 @@ export default async function QuizReviewPage({
return (
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 dark:bg-transparent">
<main className="relative z-10 mx-auto max-w-4xl px-4 sm:px-6">
<nav className="mb-4" aria-label="Breadcrumb">
<ol className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<li className="flex items-center gap-2">
<Link href="/dashboard" className="underline-offset-4 transition hover:text-[var(--accent-primary)] hover:underline">
{tNav('dashboard')}
</Link>
<span>&gt;</span>
</li>
<li>
<span className="text-[var(--accent-primary)]" aria-current="page">
{review.quizTitle ?? review.quizSlug}
</span>
</li>
</ol>
</nav>
<header className="mb-8">
<div className="flex items-center gap-3">
{categoryStyle && (
Expand Down
23 changes: 19 additions & 4 deletions frontend/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -59,8 +62,20 @@ export async function generateMetadata({

export default function Home() {
return (
<>
<HeroSection />
</>
<HomePageScroll>
<div
data-home-step
className="h-[calc(100dvh-4rem)] shrink-0 snap-start [scroll-snap-stop:always]"
>
<WelcomeHeroSection />
</div>
<div
data-home-step
className="min-h-[calc(100dvh-4rem)] shrink-0 snap-start [scroll-snap-stop:always]"
>
<FeaturesHeroSection />
</div>
<Footer forceVisible />
</HomePageScroll>
);
}
9 changes: 5 additions & 4 deletions frontend/app/[locale]/quiz/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> };

Expand Down Expand Up @@ -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)
Expand Down
20 changes: 16 additions & 4 deletions frontend/app/[locale]/shop/products/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -131,9 +134,18 @@ export default async function ProductPage({
</section>
)}

{product.description && (
<p className="text-muted-foreground mt-6">{product.description}</p>
)}
{(() => {
const desc =
(productDescriptions[slug] as string) || product.description;
if (!desc) return null;
return (
<div className="text-muted-foreground mt-6 space-y-2">
{desc.split('\n').map((line: string, i: number) => (
<p key={i}>{line}</p>
))}
</div>
);
})()}

{!isUnavailable && (
<section aria-label="Purchase">
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/api/questions/[category]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading