diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index b8966748..dddb6391 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -15,6 +15,7 @@ import { MainSwitcher } from '@/components/header/MainSwitcher'; import { AppChrome } from '@/components/header/AppChrome'; import { CookieBanner } from '@/components/shared/CookieBanner'; +import { OnlineCounterPopup } from '@/components/shared/OnlineCounterPopup'; export const dynamic = 'force-dynamic'; @@ -74,6 +75,7 @@ export default async function LocaleLayout({ {children} + diff --git a/frontend/app/api/sessions/activity/route.ts b/frontend/app/api/sessions/activity/route.ts new file mode 100644 index 00000000..cce640f9 --- /dev/null +++ b/frontend/app/api/sessions/activity/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import { db } from '@/db'; +import { activeSessions } from '@/db/schema/sessions'; +import { cookies } from 'next/headers'; +import { randomUUID } from 'crypto'; +import { sql, gte, lt } from 'drizzle-orm'; + +const SESSION_TIMEOUT_MINUTES = 15; + +export async function POST() { + try { + const cookieStore = await cookies(); + let sessionId = cookieStore.get('user_session_id')?.value; + + if (!sessionId) { + sessionId = randomUUID(); + } + + await db + .insert(activeSessions) + .values({ + sessionId, + lastActivity: new Date(), + }) + .onConflictDoUpdate({ + target: activeSessions.sessionId, + set: { lastActivity: new Date() }, + }); + + if (Math.random() < 0.05) { + const cleanupThreshold = new Date( + Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 + ); + + db.delete(activeSessions) + .where(lt(activeSessions.lastActivity, cleanupThreshold)) + .catch(err => console.error('Cleanup error:', err)); + } + + const countThreshold = new Date( + Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000 + ); + + const result = await db + .select({ + total: sql`count(distinct session_id)`, + }) + .from(activeSessions) + .where(gte(activeSessions.lastActivity, countThreshold)); + + const response = NextResponse.json({ + online: Number(result[0]?.total || 0), + }); + + response.cookies.set('user_session_id', sessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + }); + + return response; + } catch (error) { + console.error('Join error:', error); + return NextResponse.json({ online: 0 }, { status: 200 }); + } +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 957b9c02..d819bc31 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -164,6 +164,23 @@ } } +@keyframes progress { + from { + width: 100%; + } + to { + width: 0%; + } +} +@keyframes shrink { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} + /* Shop theme: scoped overrides (must not affect platform pages) */ .shop-scope { /* keep shop rounding slightly tighter than platform */ diff --git a/frontend/components/shared/OnlineCounterPopup.tsx b/frontend/components/shared/OnlineCounterPopup.tsx new file mode 100644 index 00000000..4dddd1d9 --- /dev/null +++ b/frontend/components/shared/OnlineCounterPopup.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Users, Sparkles } from 'lucide-react'; + +export function OnlineCounterPopup() { + const [online, setOnline] = useState(null); + const [show, setShow] = useState(false); + + useEffect(() => { + if (sessionStorage.getItem('shown')) return; + + fetch('/api/sessions/activity', { method: 'POST' }) + .then(r => r.json()) + .then(data => { + setOnline(data.online); + setTimeout(() => setShow(true), 500); + sessionStorage.setItem('shown', '1'); + setTimeout(() => setShow(false), 10000); + }) + .catch(() => setOnline(null)); + }, []); + + if (!online) return null; + + const getEmoji = (count: number) => { + if (count === 1) return '🎯'; + if (count === 2) return '💼'; + if (count <= 5) return '🚀'; + if (count <= 10) return '⚡'; + return '⭐'; + }; + + const getText = (count: number) => { + if (count === 1) return 'на крок до цілі'; + if (count === 2) return 'йдуть до мрії'; + if (count <= 5) return 'на шляху до оффера'; + if (count <= 10) return 'ближчі до dream job'; + return 'крокує до успіху'; + }; + + return ( + + + + + + + + + + + + + + {getEmoji(online)} + + + {online} + + + {getText(online)} + + + + + + + + + + + + + + + + + + + + + ); +}