Skip to content

Commit f959569

Browse files
committed
Merge branch 'develop' into yn/feat/users-counter
2 parents 5b1d2f4 + cb44e8a commit f959569

87 files changed

Lines changed: 14151 additions & 5261 deletions

File tree

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
Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1+
import { getPlatformStats } from "@/lib/about/stats"
2+
import { getSponsors } from "@/lib/about/github-sponsors"
3+
14
import { HeroSection } from "@/components/about/HeroSection"
5+
import { TopicsSection } from "@/components/about/TopicsSection"
26
import { FeaturesSection } from "@/components/about/FeaturesSection"
37
import { PricingSection } from "@/components/about/PricingSection"
48
import { CommunitySection } from "@/components/about/CommunitySection"
59

6-
export default function AboutPage() {
10+
export default async function AboutPage() {
11+
const [stats, sponsors] = await Promise.all([
12+
getPlatformStats(),
13+
getSponsors()
14+
])
15+
716
return (
8-
<main className="min-h-screen bg-neutral-950 text-white">
9-
<HeroSection />
17+
<main className="min-h-screen bg-gray-50 dark:bg-black overflow-hidden text-gray-900 dark:text-white
18+
w-[100vw] relative left-[50%] right-[50%] -ml-[50vw] -mr-[50vw]"
19+
>
20+
21+
<HeroSection stats={stats} />
22+
<TopicsSection />
1023
<FeaturesSection />
11-
<PricingSection />
24+
<PricingSection sponsors={sponsors} />
1225
<CommunitySection />
26+
1327
</main>
1428
)
15-
}
29+
}

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]/contacts/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,4 @@ export default function ContactsPage() {
4444
</ul>
4545
</main>
4646
);
47-
}
47+
}

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"
Lines changed: 3 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,7 @@
11
"use client";
2-
import { Link } from "@/i18n/routing";
3-
import { useState } from "react";
4-
import { useSearchParams } from "next/navigation";
5-
import { Button } from "@/components/ui/button";
62

7-
export default function ForgotPasswordPage() {
8-
const searchParams = useSearchParams();
9-
const returnTo = searchParams.get("returnTo");
10-
11-
const [loading, setLoading] = useState(false);
12-
const [email, setEmail] = useState("");
13-
const [submitted, setSubmitted] = useState(false);
14-
const [error, setError] = useState<string | null>(null);
15-
16-
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
17-
e.preventDefault();
18-
setLoading(true);
19-
setError(null);
20-
21-
try {
22-
const res = await fetch("/api/auth/password-reset", {
23-
method: "POST",
24-
headers: { "Content-Type": "application/json" },
25-
body: JSON.stringify({ email }),
26-
});
27-
28-
if (!res.ok) {
29-
setError("Something went wrong. Please try again.");
30-
return;
31-
}
32-
33-
setSubmitted(true);
34-
} catch (err) {
35-
console.error("Password reset request failed:", err);
36-
setError("Network error. Please check your connection and try again.");
37-
} finally {
38-
setLoading(false);
39-
}
40-
}
41-
42-
return (
43-
<div className="mx-auto max-w-sm py-12">
44-
<h1 className="mb-6 text-2xl font-semibold">
45-
Forgot password
46-
</h1>
3+
import { ForgotPasswordForm } from "@/components/auth/ForgotPasswordForm";
474

48-
{submitted ? (
49-
<div className="rounded-md border border-green-400 bg-green-50 p-4 text-sm text-green-800">
50-
<p>
51-
If an account for{" "}
52-
<strong>{email}</strong> exists, we’ve sent a
53-
password reset link.
54-
</p>
55-
56-
<p className="mt-2">
57-
Please check your inbox and follow the
58-
instructions to reset your password.
59-
</p>
60-
61-
<Link
62-
href={
63-
returnTo
64-
? `/login?returnTo=${encodeURIComponent(returnTo)}`
65-
: "/login"
66-
}
67-
className="mt-4 inline-block underline"
68-
>
69-
Back to login
70-
</Link>
71-
</div>
72-
) : (
73-
<form onSubmit={onSubmit} className="space-y-4">
74-
<p className="text-sm text-gray-600">
75-
Enter your email address and we’ll send
76-
you a link to reset your password.
77-
</p>
78-
79-
<input
80-
type="email"
81-
required
82-
placeholder="Email"
83-
value={email}
84-
onChange={e => setEmail(e.target.value)}
85-
className="w-full rounded border px-3 py-2"
86-
/>
87-
88-
{error && (
89-
<p className="text-sm text-red-600">
90-
{error}
91-
</p>
92-
)}
93-
94-
<Button
95-
type="submit"
96-
disabled={loading}
97-
className="w-full"
98-
>
99-
{loading
100-
? "Sending reset link..."
101-
: "Send reset link"}
102-
</Button>
103-
</form>
104-
)}
105-
106-
{!submitted && (
107-
<p className="mt-4 text-sm text-gray-600">
108-
Remembered your password?{" "}
109-
<Link
110-
href={
111-
returnTo
112-
? `/login?returnTo=${encodeURIComponent(returnTo)}`
113-
: "/login"
114-
}
115-
className="underline"
116-
>
117-
Log in
118-
</Link>
119-
</p>
120-
)}
121-
</div>
122-
);
5+
export default function ForgotPasswordPage() {
6+
return <ForgotPasswordForm />;
1237
}

0 commit comments

Comments
 (0)