Skip to content

Commit 040fdfe

Browse files
(SP: 3) [Frontend] Complete About page redesign: fix merge conflicts with develop
2 parents ecd3e99 + 1a8f5d4 commit 040fdfe

61 files changed

Lines changed: 20660 additions & 6545 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/.env.example

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,27 @@ GMAIL_USER=
6666

6767
# --- Security
6868
CSRF_SECRET=
69+
70+
CHECKOUT_RATE_LIMIT_MAX=10
71+
CHECKOUT_RATE_LIMIT_WINDOW_SECONDS=300
72+
73+
# Stripe webhook rate limit envs (applied per reason; reason-specific overrides generic).
74+
# Missing signature has its own envs with fallback to generic, then legacy invalid_sig.
75+
STRIPE_WEBHOOK_MISSING_SIG_RL_MAX=30
76+
STRIPE_WEBHOOK_MISSING_SIG_RL_WINDOW_SECONDS=60
77+
78+
# Generic Stripe webhook rate limit fallback (applies to missing_sig and invalid_sig).
79+
STRIPE_WEBHOOK_RL_MAX=30
80+
STRIPE_WEBHOOK_RL_WINDOW_SECONDS=60
81+
82+
# Invalid signature envs (canonical for invalid_sig, legacy fallback for missing_sig).
83+
STRIPE_WEBHOOK_INVALID_SIG_RL_MAX=30
84+
STRIPE_WEBHOOK_INVALID_SIG_RL_WINDOW_SECONDS=60
85+
86+
# SECURITY: If true, trust x-real-ip / x-forwarded-for headers for rate limiting.
87+
# Enable ONLY behind Cloudflare or a trusted reverse proxy that overwrites these headers.
88+
# Default: false (empty/0/false).
89+
TRUST_FORWARDED_HEADERS=0
90+
91+
# emergency switch
92+
RATE_LIMIT_DISABLED=0

frontend/app/[locale]/blog/[slug]/PostDetails.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { getTranslations } from 'next-intl/server';
55
import { client } from '@/client';
66
import { Link } from '@/i18n/routing';
77

8+
export const revalidate = 0;
9+
810
type SocialLink = {
911
_key?: string;
1012
platform?: string;
@@ -116,11 +118,15 @@ export default async function PostDetails({
116118
const slugParam = String(slug || '').trim();
117119
if (!slugParam) return notFound();
118120

119-
const post: Post | null = await client.fetch(query, {
121+
const post: Post | null = await client
122+
.withConfig({ useCdn: false })
123+
.fetch(query, {
120124
slug: slugParam,
121125
locale,
122126
});
123-
const recommendedAll: Post[] = await client.fetch(recommendedQuery, {
127+
const recommendedAll: Post[] = await client
128+
.withConfig({ useCdn: false })
129+
.fetch(recommendedQuery, {
124130
slug: slugParam,
125131
locale,
126132
});
@@ -156,7 +162,7 @@ export default async function PostDetails({
156162
href={`/blog?category=${encodeURIComponent(post.categories[0])}`}
157163
className="inline-flex items-center gap-1 hover:text-[#ff00ff] transition"
158164
>
159-
{post.categories[0]}
165+
{post.categories[0] === 'Growth' ? 'Career' : post.categories[0]}
160166
</Link>
161167
</div>
162168
)}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import groq from 'groq';
2+
import { notFound } from 'next/navigation';
3+
import { getTranslations } from 'next-intl/server';
4+
import { client } from '@/client';
5+
import { BlogCategoryGrid } from '@/components/blog/BlogCategoryGrid';
6+
7+
export const revalidate = 0;
8+
9+
type Author = {
10+
name?: string;
11+
image?: string;
12+
};
13+
14+
type Post = {
15+
_id: string;
16+
title: string;
17+
slug: { current: string };
18+
publishedAt?: string;
19+
categories?: string[];
20+
mainImage?: string;
21+
body?: any[];
22+
author?: Author;
23+
};
24+
25+
type Category = {
26+
_id: string;
27+
title: string;
28+
};
29+
30+
const categoriesQuery = groq`
31+
*[_type == "category"] | order(orderRank asc) {
32+
_id,
33+
title
34+
}
35+
`;
36+
37+
export default async function BlogCategoryPage({
38+
params,
39+
}: {
40+
params: Promise<{ locale: string; category: string }>;
41+
}) {
42+
const { locale, category } = await params;
43+
const t = await getTranslations({ locale, namespace: 'blog' });
44+
const categoryKey = String(category || '').toLowerCase();
45+
const categories: Category[] = await client
46+
.withConfig({ useCdn: false })
47+
.fetch(categoriesQuery);
48+
const matchedCategory = categories.find(
49+
item => slugify(item.title) === categoryKey
50+
);
51+
52+
if (!matchedCategory) return notFound();
53+
const categoryTitle = matchedCategory.title;
54+
const displayTitle =
55+
categoryTitle === 'Growth' ? 'Career' : categoryTitle;
56+
57+
const posts: Post[] = await client.withConfig({ useCdn: false }).fetch(
58+
groq`
59+
*[_type == "post" && defined(slug.current) && $category in categories[]->title]
60+
| order(publishedAt desc) {
61+
_id,
62+
"title": coalesce(title[$locale], title[lower($locale)], title.uk, title.en, title.pl, title),
63+
slug,
64+
publishedAt,
65+
"categories": categories[]->title,
66+
"body": coalesce(body[$locale], body[lower($locale)], body.uk, body.en, body.pl, body)[]{
67+
...,
68+
children[]{ text }
69+
},
70+
"mainImage": mainImage.asset->url,
71+
"author": author->{
72+
"name": coalesce(name[$locale], name[lower($locale)], name.uk, name.en, name.pl, name),
73+
"image": image.asset->url
74+
}
75+
}
76+
`,
77+
{ locale, category: categoryTitle }
78+
);
79+
80+
return (
81+
<main className="max-w-6xl mx-auto px-6 py-12">
82+
<h1 className="text-4xl font-bold mb-4 text-center">
83+
{displayTitle}
84+
</h1>
85+
<div className="mt-12">
86+
<BlogCategoryGrid posts={posts} />
87+
</div>
88+
{!posts.length && (
89+
<p className="text-center text-gray-500 mt-10">{t('noPosts')}</p>
90+
)}
91+
</main>
92+
);
93+
}
94+
95+
function slugify(value: string) {
96+
return value
97+
.toLowerCase()
98+
.trim()
99+
.replace(/[^a-z0-9\s-]/g, '')
100+
.replace(/\s+/g, '-');
101+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { getTranslations } from 'next-intl/server';
33
import { client } from '@/client';
44
import BlogFilters from '@/components/blog/BlogFilters';
55

6+
export const revalidate = 0;
7+
68
export async function generateMetadata({
79
params,
810
}: {
@@ -25,7 +27,7 @@ export default async function BlogPage({
2527
const { locale } = await params;
2628
const t = await getTranslations({ locale, namespace: 'blog' });
2729

28-
const posts = await client.fetch(
30+
const posts = await client.withConfig({ useCdn: false }).fetch(
2931
groq`
3032
*[_type == "post" && defined(slug.current)]
3133
| order(publishedAt desc) {
@@ -62,7 +64,7 @@ export default async function BlogPage({
6264
`,
6365
{ locale }
6466
);
65-
const categories = await client.fetch(
67+
const categories = await client.withConfig({ useCdn: false }).fetch(
6668
groq`
6769
*[_type == "category"] | order(orderRank asc) {
6870
_id,

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getUserQuizStats } from '@/db/queries/quiz';
77
import { ProfileCard } from '@/components/dashboard/ProfileCard';
88
import { StatsCard } from '@/components/dashboard/StatsCard';
99
import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
10+
import { PostAuthQuizSync } from "@/components/auth/PostAuthQuizSync";
1011

1112
export const metadata = {
1213
title: 'Dashboard | DevLovers',
@@ -28,9 +29,9 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
2829
const averageScore =
2930
totalAttempts > 0
3031
? Math.round(
31-
attempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
32-
totalAttempts
33-
)
32+
attempts.reduce((acc, curr) => acc + Number(curr.percentage), 0) /
33+
totalAttempts
34+
)
3435
: 0;
3536

3637
const lastActiveDate =
@@ -57,6 +58,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
5758

5859
return (
5960
<main className="relative min-h-[calc(100vh-80px)] overflow-hidden">
61+
<PostAuthQuizSync />
6062
<div
6163
className="absolute inset-0 pointer-events-none -z-10"
6264
aria-hidden="true"

frontend/app/[locale]/layout.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { Toaster } from 'sonner';
33
import { NextIntlClientProvider } from 'next-intl';
44
import { getMessages } from 'next-intl/server';
55
import { notFound } from 'next/navigation';
6+
import groq from 'groq';
67

78
import { locales } from '@/i18n/config';
89
import Footer from '@/components/shared/Footer';
910
import { ThemeProvider } from '@/components/theme/ThemeProvider';
1011
import { getCurrentUser } from '@/lib/auth';
12+
import { client } from '@/client';
1113

1214
import { MainSwitcher } from '@/components/header/MainSwitcher';
1315
import { AppChrome } from '@/components/header/AppChrome';
@@ -29,6 +31,16 @@ export default async function LocaleLayout({
2931

3032
const messages = await getMessages({ locale });
3133
const user = await getCurrentUser();
34+
const blogCategories: Array<{ _id: string; title: string }> = await client
35+
.withConfig({ useCdn: false })
36+
.fetch(
37+
groq`
38+
*[_type == "category"] | order(orderRank asc) {
39+
_id,
40+
title
41+
}
42+
`
43+
);
3244

3345
const userExists = Boolean(user);
3446
const enableAdmin =
@@ -49,8 +61,18 @@ export default async function LocaleLayout({
4961
enableSystem
5062
disableTransitionOnChange
5163
>
52-
<AppChrome userExists={userExists} showAdminLink={showAdminNavLink}>
53-
<MainSwitcher>{children}</MainSwitcher>
64+
<AppChrome
65+
userExists={userExists}
66+
showAdminLink={showAdminNavLink}
67+
blogCategories={blogCategories}
68+
>
69+
<MainSwitcher
70+
userExists={userExists}
71+
showAdminLink={showAdminNavLink}
72+
blogCategories={blogCategories}
73+
>
74+
{children}
75+
</MainSwitcher>
5476
</AppChrome>
5577

5678
<Footer />

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

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,6 @@ import { useLocale } from "next-intl";
44
import { Link } from "@/i18n/routing";
55
import { useState } from "react";
66
import { useSearchParams } from "next/navigation";
7-
import {
8-
getPendingQuizResult,
9-
clearPendingQuizResult,
10-
} from "@/lib/quiz/guest-quiz";
117
import { Button } from "@/components/ui/button";
128
import { OAuthButtons } from "@/components/auth/OAuthButtons";
139

@@ -74,50 +70,6 @@ export default function LoginPage() {
7470
return;
7571
}
7672

77-
const pendingResult = getPendingQuizResult();
78-
79-
if (pendingResult && data?.userId) {
80-
try {
81-
const quizRes = await fetch("/api/quiz/guest-result", {
82-
method: "POST",
83-
headers: { "Content-Type": "application/json" },
84-
body: JSON.stringify({
85-
userId: data.userId,
86-
quizId: pendingResult.quizId,
87-
answers: pendingResult.answers,
88-
violations: pendingResult.violations,
89-
timeSpentSeconds:
90-
pendingResult.timeSpentSeconds,
91-
}),
92-
});
93-
94-
if (!quizRes.ok) {
95-
throw new Error(
96-
`Failed to save quiz result: ${quizRes.status}`
97-
);
98-
}
99-
100-
const result = await quizRes.json();
101-
102-
if (result.success) {
103-
sessionStorage.setItem(
104-
"quiz_just_saved",
105-
JSON.stringify({
106-
score: result.score,
107-
total: result.totalQuestions,
108-
percentage: result.percentage,
109-
pointsAwarded: result.pointsAwarded,
110-
quizSlug: pendingResult.quizSlug,
111-
})
112-
);
113-
}
114-
} catch (err) {
115-
console.error("Failed to save quiz result:", err);
116-
} finally {
117-
clearPendingQuizResult();
118-
}
119-
}
120-
12173
const redirectTarget =
12274
returnTo && isSafeRedirectUrl(returnTo)
12375
? returnTo

frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { db } from '@/db';
1111
import { products, productPrices } from '@/db/schema';
1212
import type { CurrencyCode } from '@/lib/shop/currency';
1313
import { currencyValues } from '@/lib/shop/currency';
14+
import { issueCsrfToken } from '@/lib/security/csrf';
1415

1516
export const dynamic = 'force-dynamic';
1617

@@ -75,6 +76,7 @@ export default async function EditProductPage({
7576
: parseMajorToMinor(product.originalPrice),
7677
},
7778
];
79+
const csrfToken = issueCsrfToken('admin:products:update');
7880

7981
return (
8082
<>
@@ -83,6 +85,7 @@ export default async function EditProductPage({
8385
<ProductForm
8486
mode="edit"
8587
productId={product.id}
88+
csrfToken={csrfToken}
8689
initialValues={{
8790
title: product.title,
8891
slug: product.slug,

0 commit comments

Comments
 (0)