Skip to content

Commit aa74add

Browse files
ViktorSvertokaliudmylasovetovsLesiaUKR
authored
Release v1.0.5 (#374)
* (SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow (#364) * (SP: 2) [Frontend] Reduce Vercel variable costs via caching and analytics cleanup (#367) * perf(vercel): cut runtime costs via notification, blog cache, and analytics changes * perf(blog): remove server searchParams usage to preserve ISR * fix(build): align Netlify Node version and remove SpeedInsights import * chore(release): bump version to 1.0.4 * (SP: 2) [Frontend] Remove [locale] layout force-dynamic and move auth to client-side (#370) * refactor(frontend): remove locale layout dynamic auth and move header auth client-side * fix(frontend): prevent stale auth responses in useAuth and remove redundant dashboard dynamic layout * (SP: 2) [Frontend] Reduce auth overhead and sync auth state across tabs (#372) * refactor(frontend): remove locale layout dynamic auth and move header auth client-side * fix(frontend): prevent stale auth responses in useAuth and remove redundant dashboard dynamic layout * feat(frontend): sync auth state across tabs via BroadcastChannel * (SP: 2) [Frontend] Quizzes page ISR + client-side progress + GitHub stars cache (#371) * perf(quiz-flow): move quiz progress to client-side fetch, enable ISR for quizzes page - Move user progress fetch from SSR to client-side API (/api/quiz/progress) - Remove force-dynamic and getCurrentUser() from quizzes page - Add revalidate=300 for ISR caching - Use window.history.replaceState for tab URL sync (avoid Next.js navigation) - Add forceMount to TabsContent to prevent layout shift on tab switch - Fix nested <main> — use <section> inside DynamicGridBackground - Cache GitHub stars count in sessionStorage to avoid refetch + re-animation * perf: replace useRef with useState lazy initializer in GitHubStarButton Fixes React 19 react-hooks/refs ESLint error — useRef.current cannot be read during render. Uses useState(getStoredStars) to capture the sessionStorage value once on mount instead. * fix: stop star icon trembling on hover in GitHubStarButton * (SP: 1) [Frontend] Fix quiz timer flash and card layout shift on quizzes page (#373) * perf(quiz-flow): move quiz progress to client-side fetch, enable ISR for quizzes page - Move user progress fetch from SSR to client-side API (/api/quiz/progress) - Remove force-dynamic and getCurrentUser() from quizzes page - Add revalidate=300 for ISR caching - Use window.history.replaceState for tab URL sync (avoid Next.js navigation) - Add forceMount to TabsContent to prevent layout shift on tab switch - Fix nested <main> — use <section> inside DynamicGridBackground - Cache GitHub stars count in sessionStorage to avoid refetch + re-animation * perf: replace useRef with useState lazy initializer in GitHubStarButton Fixes React 19 react-hooks/refs ESLint error — useRef.current cannot be read during render. Uses useState(getStoredStars) to capture the sessionStorage value once on mount instead. * fix: stop star icon trembling on hover in GitHubStarButton * fix: eliminate quiz timer flash on language switch Remove Suspense boundary (loading.tsx) that unmounted QuizContainer during locale navigation. Synchronous session restore via useReducer lazy initializer and correct timer initialization via useState lazy initializer prevent any visible state reset on language switch * fix: replace quiz card layout shift with skeleton grid during progress load * chore(release): v1.0.5 --------- Co-authored-by: Liudmyla Sovetovs <milkaegik@gmail.com> Co-authored-by: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com>
1 parent 1b38291 commit aa74add

22 files changed

Lines changed: 490 additions & 154 deletions

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,3 +747,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
747747
- Lower Vercel Function Invocations and CPU usage
748748
- Reduced origin data transfer for blog content
749749
- Improved overall runtime efficiency
750+
751+
## [1.0.5] - 2026-02-26
752+
753+
### Added
754+
755+
- Auth improvements:
756+
- Cross-tab authentication sync via BroadcastChannel
757+
- Client-side auth handling in Header for faster UI updates
758+
759+
### Changed
760+
761+
- Quizzes performance:
762+
- Quizzes page now uses ISR (revalidate: 300)
763+
- User progress moved from SSR to client-side API (`/api/quiz/progress`)
764+
- URL tab sync via `history.replaceState` without navigation
765+
- GitHub stars cached in `sessionStorage` to prevent refetch and re-animation
766+
- Rendering optimization:
767+
- Removed `force-dynamic` from locale layout
768+
- Reduced authentication overhead and dynamic rendering
769+
- Replaced nested `<main>` structure with semantic `<section>`
770+
771+
### Fixed
772+
773+
- Fixed quiz timer flash when switching language
774+
- Fixed layout shift on quizzes page (skeleton grid during progress loading)
775+
- Fixed GitHub star button hover trembling
776+
- Fixed React 19 `useRef` render warning (lazy `useState` initializer)
777+
- Prevented stale auth state across tabs
778+
- Eliminated layout shift when switching quiz tabs
779+
780+
### Performance
781+
782+
- Reduced server load by moving auth and progress logic to client
783+
- Improved ISR caching efficiency for quizzes page
784+
- Faster navigation and more stable UI during locale and tab changes

frontend/app/[locale]/layout.tsx

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ import { CookieBanner } from '@/components/shared/CookieBanner';
1313
import Footer from '@/components/shared/Footer';
1414
import { ScrollWatcher } from '@/components/shared/ScrollWatcher';
1515
import { ThemeProvider } from '@/components/theme/ThemeProvider';
16+
import { AuthProvider } from '@/hooks/useAuth';
1617
import { locales } from '@/i18n/config';
17-
import { getCurrentUser } from '@/lib/auth';
18-
19-
export const dynamic = 'force-dynamic';
2018

2119
const getCachedBlogCategories = unstable_cache(
2220
async () =>
@@ -41,22 +39,18 @@ export default async function LocaleLayout({
4139

4240
if (!locales.includes(locale as any)) notFound();
4341

44-
const messages = await getMessages({ locale });
45-
const user = await getCurrentUser();
46-
const blogCategories = await getCachedBlogCategories();
42+
const [messages, blogCategories] = await Promise.all([
43+
getMessages({ locale }),
44+
getCachedBlogCategories(),
45+
]);
4746

48-
const userExists = Boolean(user);
4947
const enableAdmin =
5048
(
5149
process.env.ENABLE_ADMIN_API ??
5250
process.env.NEXT_PUBLIC_ENABLE_ADMIN ??
5351
''
5452
).toLowerCase() === 'true';
5553

56-
const isAdmin = user?.role === 'admin';
57-
const showAdminNavLink = Boolean(user) && isAdmin && enableAdmin;
58-
const userId = user?.id ?? null;
59-
6054
return (
6155
<NextIntlClientProvider messages={messages}>
6256
<ThemeProvider
@@ -65,20 +59,19 @@ export default async function LocaleLayout({
6559
enableSystem
6660
disableTransitionOnChange
6761
>
68-
<AppChrome
69-
userExists={userExists}
70-
userId={userId}
71-
showAdminLink={showAdminNavLink}
72-
blogCategories={blogCategories}
73-
>
74-
<MainSwitcher
75-
userExists={userExists}
76-
showAdminLink={showAdminNavLink}
62+
<AuthProvider>
63+
<AppChrome
64+
enableAdminFeature={enableAdmin}
7765
blogCategories={blogCategories}
7866
>
79-
{children}
80-
</MainSwitcher>
81-
</AppChrome>
67+
<MainSwitcher
68+
enableAdminFeature={enableAdmin}
69+
blogCategories={blogCategories}
70+
>
71+
{children}
72+
</MainSwitcher>
73+
</AppChrome>
74+
</AuthProvider>
8275

8376
<Footer />
8477
<Toaster position="top-right" richColors expand />

frontend/app/[locale]/quiz/[slug]/loading.tsx

Lines changed: 0 additions & 9 deletions
This file was deleted.

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

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import QuizzesSection from '@/components/quiz/QuizzesSection';
55
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
66
import {
77
getActiveQuizzes,
8-
getUserQuizzesProgress,
98
} from '@/db/queries/quizzes/quiz';
10-
import { getCurrentUser } from '@/lib/auth';
119

1210
type PageProps = { params: Promise<{ locale: string }> };
1311

@@ -23,22 +21,14 @@ export async function generateMetadata({
2321
};
2422
}
2523

26-
export const dynamic = 'force-dynamic';
24+
export const revalidate = 300
2725

2826
export default async function QuizzesPage({ params }: PageProps) {
2927
const { locale } = await params;
3028
const t = await getTranslations({ locale, namespace: 'quiz.list' });
31-
const session = await getCurrentUser();
3229

3330
const quizzes = await getActiveQuizzes(locale);
3431

35-
let userProgressMap: Record<string, any> = {};
36-
37-
if (session?.id) {
38-
const progressMapData = await getUserQuizzesProgress(session.id);
39-
userProgressMap = Object.fromEntries(progressMapData);
40-
}
41-
4232
if (!quizzes.length) {
4333
return (
4434
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
@@ -50,7 +40,7 @@ export default async function QuizzesPage({ params }: PageProps) {
5040

5141
return (
5242
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 transition-colors duration-300 dark:bg-transparent">
53-
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
43+
<section className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
5444
<div className="mb-8">
5545
<p className="text-sm font-semibold text-(--accent-primary)">
5646
{t('practice')}
@@ -59,8 +49,8 @@ export default async function QuizzesPage({ params }: PageProps) {
5949
<p className="text-gray-600 dark:text-gray-400">{t('subtitle')}</p>
6050
</div>
6151

62-
<QuizzesSection quizzes={quizzes} userProgressMap={userProgressMap} />
63-
</main>
52+
<QuizzesSection quizzes={quizzes} userProgressMap={{}} />
53+
</section>
6454
</DynamicGridBackground>
6555
);
6656
}

frontend/app/api/auth/me/route.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import 'server-only';
22

33
import { NextResponse } from 'next/server';
44

5-
import { getCurrentUser } from '@/lib/auth';
5+
import { getAuthSession } from '@/lib/auth';
66

77
export async function GET() {
8-
const user = await getCurrentUser();
9-
return NextResponse.json({ user }, { status: 200 });
8+
const session = await getAuthSession();
9+
const payload = session ? { id: session.id, role: session.role } : null;
10+
11+
return NextResponse.json(payload, {
12+
status: 200,
13+
headers: {
14+
'Cache-Control': 'no-store',
15+
},
16+
});
1017
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { getUserQuizzesProgress } from '@/db/queries/quizzes/quiz';
4+
import { getCurrentUser } from '@/lib/auth';
5+
6+
export const runtime = 'nodejs';
7+
8+
export async function GET() {
9+
const user = await getCurrentUser();
10+
11+
if (!user?.id) {
12+
return NextResponse.json({}, {
13+
headers: { 'Cache-Control': 'no-store' },
14+
});
15+
}
16+
17+
const rawProgress = await getUserQuizzesProgress(user.id);
18+
const progressMap: Record<string, { bestScore: number; totalQuestions: number; attemptsCount: number }> = {};
19+
20+
for (const [quizId, progress] of rawProgress) {
21+
progressMap[quizId] = {
22+
bestScore: progress.bestScore,
23+
totalQuestions: progress.totalQuestions,
24+
attemptsCount: progress.attemptsCount,
25+
};
26+
}
27+
28+
return NextResponse.json(progressMap, {
29+
headers: { 'Cache-Control': 'no-store' },
30+
});
31+
}

frontend/components/auth/LoginForm.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { EmailField } from '@/components/auth/fields/EmailField';
1111
import { PasswordField } from '@/components/auth/fields/PasswordField';
1212
import { Button } from '@/components/ui/button';
1313
import { Link } from '@/i18n/routing';
14+
import { broadcastAuthUpdated } from '@/lib/auth-sync';
1415

1516
type LoginFormProps = {
1617
locale: string;
@@ -59,6 +60,7 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) {
5960
return;
6061
}
6162

63+
broadcastAuthUpdated();
6264
window.location.href = returnTo || `/${locale}/dashboard`;
6365
} catch (err) {
6466
console.error('Login request failed:', err);

frontend/components/auth/SignupForm.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
PASSWORD_MIN_LEN,
2323
PASSWORD_POLICY_REGEX,
2424
} from '@/lib/auth/signup-constraints';
25+
import { broadcastAuthUpdated } from '@/lib/auth-sync';
2526

2627
type SignupFormProps = {
2728
locale: string;
@@ -173,6 +174,7 @@ export function SignupForm({ locale, returnTo }: SignupFormProps) {
173174
return;
174175
}
175176

177+
broadcastAuthUpdated();
176178
window.location.href = returnTo || `/${locale}/dashboard`;
177179
} catch {
178180
setError(t('errors.networkError'));

frontend/components/header/AppChrome.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,24 @@ import React from 'react';
55

66
import { UnifiedHeader } from '@/components/header/UnifiedHeader';
77
import { CartProvider } from '@/components/shop/CartProvider';
8+
import { useAuth } from '@/hooks/useAuth';
89

910
type AppChromeProps = {
10-
userExists: boolean;
11-
userId?: string | null;
12-
showAdminLink?: boolean;
11+
enableAdminFeature?: boolean;
1312
blogCategories?: Array<{ _id: string; title: string }>;
1413
children: React.ReactNode;
1514
};
1615

1716
export function AppChrome({
18-
userExists,
19-
userId = null,
20-
showAdminLink = false,
17+
enableAdminFeature = false,
2118
blogCategories = [],
2219
children,
2320
}: AppChromeProps) {
21+
const { userExists, userId, isAdmin } = useAuth();
2422
const segments = useSelectedLayoutSegments();
2523
const isShop = segments.includes('shop');
2624
const isBlog = segments.includes('blog');
25+
const showAdminLink = userExists && isAdmin && enableAdminFeature;
2726

2827
if (isShop) {
2928
return (

frontend/components/header/MainSwitcher.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
44
import type { ReactNode } from 'react';
55

66
import { UnifiedHeader } from '@/components/header/UnifiedHeader';
7+
import { useAuth } from '@/hooks/useAuth';
78
import { locales } from '@/i18n/config';
89

910
function isShopPath(pathname: string): boolean {
@@ -52,20 +53,20 @@ function isLeaderboardPath(pathname: string): boolean {
5253

5354
type MainSwitcherProps = {
5455
children: ReactNode;
55-
userExists: boolean;
56-
showAdminLink?: boolean;
56+
enableAdminFeature?: boolean;
5757
blogCategories?: Array<{ _id: string; title: string }>;
5858
};
5959

6060
export function MainSwitcher({
6161
children,
62-
userExists,
63-
showAdminLink = false,
62+
enableAdminFeature = false,
6463
blogCategories = [],
6564
}: MainSwitcherProps) {
65+
const { userExists, isAdmin } = useAuth();
6666
const pathname = usePathname();
6767
const isQa = isQaPath(pathname);
6868
const isHome = isHomePath(pathname);
69+
const showAdminLink = userExists && isAdmin && enableAdminFeature;
6970

7071
if (isShopPath(pathname)) return <>{children}</>;
7172

0 commit comments

Comments
 (0)