Skip to content

Commit 9be8fcc

Browse files
Merge pull request #163 from DevLoversTeam/yn/feat/users-counter
2 parents cb44e8a + f959569 commit 9be8fcc

4 files changed

Lines changed: 188 additions & 0 deletions

File tree

frontend/app/[locale]/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { MainSwitcher } from '@/components/header/MainSwitcher';
1515
import { AppChrome } from '@/components/header/AppChrome';
1616

1717
import { CookieBanner } from '@/components/shared/CookieBanner';
18+
import { OnlineCounterPopup } from '@/components/shared/OnlineCounterPopup';
1819

1920
export const dynamic = 'force-dynamic';
2021

@@ -74,6 +75,7 @@ export default async function LocaleLayout({
7475
{children}
7576
</MainSwitcher>
7677
</AppChrome>
78+
<OnlineCounterPopup />
7779

7880
<Footer />
7981
<Toaster position="top-right" richColors expand />
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NextResponse } from 'next/server';
2+
import { db } from '@/db';
3+
import { activeSessions } from '@/db/schema/sessions';
4+
import { cookies } from 'next/headers';
5+
import { randomUUID } from 'crypto';
6+
import { sql, gte, lt } from 'drizzle-orm';
7+
8+
const SESSION_TIMEOUT_MINUTES = 15;
9+
10+
export async function POST() {
11+
try {
12+
const cookieStore = await cookies();
13+
let sessionId = cookieStore.get('user_session_id')?.value;
14+
15+
if (!sessionId) {
16+
sessionId = randomUUID();
17+
}
18+
19+
await db
20+
.insert(activeSessions)
21+
.values({
22+
sessionId,
23+
lastActivity: new Date(),
24+
})
25+
.onConflictDoUpdate({
26+
target: activeSessions.sessionId,
27+
set: { lastActivity: new Date() },
28+
});
29+
30+
if (Math.random() < 0.05) {
31+
const cleanupThreshold = new Date(
32+
Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000
33+
);
34+
35+
db.delete(activeSessions)
36+
.where(lt(activeSessions.lastActivity, cleanupThreshold))
37+
.catch(err => console.error('Cleanup error:', err));
38+
}
39+
40+
const countThreshold = new Date(
41+
Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000
42+
);
43+
44+
const result = await db
45+
.select({
46+
total: sql<number>`count(distinct session_id)`,
47+
})
48+
.from(activeSessions)
49+
.where(gte(activeSessions.lastActivity, countThreshold));
50+
51+
const response = NextResponse.json({
52+
online: Number(result[0]?.total || 0),
53+
});
54+
55+
response.cookies.set('user_session_id', sessionId, {
56+
httpOnly: true,
57+
secure: process.env.NODE_ENV === 'production',
58+
sameSite: 'lax',
59+
maxAge: 60 * 60 * 24 * 30,
60+
});
61+
62+
return response;
63+
} catch (error) {
64+
console.error('Join error:', error);
65+
return NextResponse.json({ online: 0 }, { status: 200 });
66+
}
67+
}

frontend/app/globals.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,23 @@
164164
}
165165
}
166166

167+
@keyframes progress {
168+
from {
169+
width: 100%;
170+
}
171+
to {
172+
width: 0%;
173+
}
174+
}
175+
@keyframes shrink {
176+
from {
177+
transform: scaleX(1);
178+
}
179+
to {
180+
transform: scaleX(0);
181+
}
182+
}
183+
167184
/* Shop theme: scoped overrides (must not affect platform pages) */
168185
.shop-scope {
169186
/* keep shop rounding slightly tighter than platform */
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { Users, Sparkles } from 'lucide-react';
5+
6+
export function OnlineCounterPopup() {
7+
const [online, setOnline] = useState<number | null>(null);
8+
const [show, setShow] = useState(false);
9+
10+
useEffect(() => {
11+
if (sessionStorage.getItem('shown')) return;
12+
13+
fetch('/api/sessions/activity', { method: 'POST' })
14+
.then(r => r.json())
15+
.then(data => {
16+
setOnline(data.online);
17+
setTimeout(() => setShow(true), 500);
18+
sessionStorage.setItem('shown', '1');
19+
setTimeout(() => setShow(false), 10000);
20+
})
21+
.catch(() => setOnline(null));
22+
}, []);
23+
24+
if (!online) return null;
25+
26+
const getEmoji = (count: number) => {
27+
if (count === 1) return '🎯';
28+
if (count === 2) return '💼';
29+
if (count <= 5) return '🚀';
30+
if (count <= 10) return '⚡';
31+
return '⭐';
32+
};
33+
34+
const getText = (count: number) => {
35+
if (count === 1) return 'на крок до цілі';
36+
if (count === 2) return 'йдуть до мрії';
37+
if (count <= 5) return 'на шляху до оффера';
38+
if (count <= 10) return 'ближчі до dream job';
39+
return 'крокує до успіху';
40+
};
41+
42+
return (
43+
<div className="fixed bottom-12 right-12 z-50 max-w-md">
44+
<div
45+
className={`
46+
transition-all duration-500 ease-out
47+
${
48+
show
49+
? 'translate-y-0 opacity-100 scale-100'
50+
: 'translate-y-4 opacity-0 scale-90'
51+
}
52+
`}
53+
>
54+
<div className="absolute inset-0 bg-gradient-to-r from-blue-400/20 via-purple-400/20 to-pink-400/20 dark:from-blue-500/30 dark:via-purple-500/30 dark:to-pink-500/30 blur-2xl animate-pulse" />
55+
56+
<div className="relative inline-flex items-center gap-3 px-5 py-3.5 rounded-2xl bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 dark:from-blue-600 dark:via-purple-600 dark:to-pink-600 shadow-2xl">
57+
<div className="relative flex-shrink-0">
58+
<div className="w-10 h-10 rounded-xl bg-white/30 dark:bg-white/25 backdrop-blur-sm flex items-center justify-center">
59+
<Users className="w-5 h-5 text-white" />
60+
</div>
61+
<Sparkles
62+
className="absolute -top-1 -right-1 w-4 h-4 text-yellow-300 dark:text-yellow-200 animate-spin"
63+
style={{ animationDuration: '3s' }}
64+
/>
65+
</div>
66+
67+
<div className="flex items-baseline gap-2">
68+
<span className="text-xl">{getEmoji(online)}</span>
69+
<div className="flex items-baseline gap-1.5">
70+
<span className="text-2xl font-black text-white dark:text-yellow-100 drop-shadow-sm">
71+
{online}
72+
</span>
73+
<span className="text-base font-semibold text-white/95 dark:text-white/90 whitespace-nowrap">
74+
{getText(online)}
75+
</span>
76+
</div>
77+
</div>
78+
79+
<div className="ml-1 flex-shrink-0">
80+
<span className="relative flex h-2.5 w-2.5">
81+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-300 dark:bg-purple-200 opacity-75" />
82+
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-purple-400 dark:bg-purple-300" />
83+
</span>
84+
</div>
85+
86+
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/25 dark:bg-white/20 rounded-b-2xl overflow-hidden">
87+
<div
88+
className="h-full bg-white/60 dark:bg-white/50"
89+
style={{ animation: 'shrink 8s linear forwards' }}
90+
/>
91+
</div>
92+
</div>
93+
94+
<div className="absolute -top-1 left-6 w-1.5 h-1.5 rounded-full bg-blue-400/60 dark:bg-blue-300/70 animate-ping" />
95+
<div
96+
className="absolute -top-2 right-8 w-1 h-1 rounded-full bg-purple-400/60 dark:bg-purple-300/70 animate-ping"
97+
style={{ animationDelay: '0.5s' }}
98+
/>
99+
</div>
100+
</div>
101+
);
102+
}

0 commit comments

Comments
 (0)