Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -74,6 +75,7 @@ export default async function LocaleLayout({
{children}
</MainSwitcher>
</AppChrome>
<OnlineCounterPopup />

<Footer />
<Toaster position="top-right" richColors expand />
Expand Down
67 changes: 67 additions & 0 deletions frontend/app/api/sessions/activity/route.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Comment on lines +30 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cleanup may never run if the request ends early.
In serverless runtimes, un-awaited DB calls can be terminated when the response returns, leaving stale sessions uncollected and the table growing. Consider awaiting the cleanup (or moving it to a scheduled job) while still swallowing errors to keep the request resilient.

🛠️ Suggested fix (await cleanup safely)
-    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));
-    }
+    if (Math.random() < 0.05) {
+      const cleanupThreshold = new Date(
+        Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000
+      );
+
+      try {
+        await db
+          .delete(activeSessions)
+          .where(lt(activeSessions.lastActivity, cleanupThreshold));
+      } catch (err) {
+        console.error('Cleanup error:', err);
+      }
+    }
🤖 Prompt for AI Agents
In `@frontend/app/api/sessions/activity/route.ts` around lines 30 - 38, The
fire-and-forget cleanup call can be killed by serverless request termination;
modify the logic around
db.delete(activeSessions).where(lt(activeSessions.lastActivity,
cleanupThreshold)) so the cleanup is awaited (or moved to a scheduled/background
job instead) while still swallowing errors—i.e., await the delete promise and
catch/log errors (using the existing catch pattern) or extract this cleanup into
a cron/worker; reference the symbols activeSessions, lastActivity,
SESSION_TIMEOUT_MINUTES and db.delete to locate and update the code.


const countThreshold = new Date(
Date.now() - SESSION_TIMEOUT_MINUTES * 60 * 1000
);

const result = await db
.select({
total: sql<number>`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 });
}
}
17 changes: 17 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
102 changes: 102 additions & 0 deletions frontend/components/shared/OnlineCounterPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use client';

import { useEffect, useState } from 'react';
import { Users, Sparkles } from 'lucide-react';

export function OnlineCounterPopup() {
const [online, setOnline] = useState<number | null>(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));
Comment on lines +10 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Session tracking stops after the first display in a tab.
The early sessionStorage return prevents subsequent POSTs, so lastActivity isn’t refreshed and long-lived sessions will fall out of the 15‑minute window even while the user is active. You can still call the API while gating only the UI display. Also consider cleaning up timeouts to avoid state updates after unmount.

🛠️ Suggested fix (always ping, gate UI, and clear timers)
-  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));
-  }, []);
+  useEffect(() => {
+    const alreadyShown = sessionStorage.getItem('shown');
+    let showTimer: ReturnType<typeof setTimeout> | undefined;
+    let hideTimer: ReturnType<typeof setTimeout> | undefined;
+
+    fetch('/api/sessions/activity', { method: 'POST' })
+      .then(r => r.json())
+      .then(data => {
+        setOnline(data.online);
+        if (!alreadyShown) {
+          showTimer = setTimeout(() => setShow(true), 500);
+          sessionStorage.setItem('shown', '1');
+          hideTimer = setTimeout(() => setShow(false), 10000);
+        }
+      })
+      .catch(() => setOnline(null));
+
+    return () => {
+      if (showTimer) clearTimeout(showTimer);
+      if (hideTimer) clearTimeout(hideTimer);
+    };
+  }, []);
🤖 Prompt for AI Agents
In `@frontend/components/shared/OnlineCounterPopup.tsx` around lines 10 - 21, In
OnlineCounterPopup's useEffect: remove the early return that blocks the fetch so
the POST to '/api/sessions/activity' always runs (so lastActivity is refreshed),
but still gate the UI show/hide using sessionStorage.getItem('shown') when
deciding whether to call setShow; store the timeout IDs returned from setTimeout
in local variables, use a cleanup function to clear those timeouts and set an
"unmounted" flag (or check mounted state) to avoid calling setShow/setOnline
after unmount, and ensure sessionStorage.setItem('shown','1') is only used to
prevent future UI displays, not to skip the API ping.

}, []);

if (!online) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n frontend/components/shared/OnlineCounterPopup.tsx

Repository: DevLoversTeam/devlovers.net

Length of output: 4716


🏁 Script executed:

fd -t f -e ts -e tsx -e js -e json | xargs rg -l "sessions/activity|online" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 255


🏁 Script executed:

cat -n frontend/app/api/sessions/activity/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 2324


Use an explicit null check instead of falsy check to support displaying "0 online."
The API returns 0 when there are no active sessions (line 52 of route.ts), but if (!online) hides the popup for this valid value. Change to if (online === null) return null; if you want to display "0 online" with appropriate copy/emoji.

🤖 Prompt for AI Agents
In `@frontend/components/shared/OnlineCounterPopup.tsx` at line 24, The component
currently hides the popup for falsy online counts; update OnlineCounterPopup.tsx
to use an explicit null check on the `online` prop/variable (replace `if
(!online) return null;` with `if (online === null) return null;`) so that a
valid value of 0 is rendered (and adjust the displayed copy/emoji in the render
path to correctly show "0 online" where appropriate).


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 (
<div className="fixed bottom-12 right-12 z-50 max-w-md">
<div
className={`
transition-all duration-500 ease-out
${
show
? 'translate-y-0 opacity-100 scale-100'
: 'translate-y-4 opacity-0 scale-90'
}
`}
>
<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" />

<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">
<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-xl bg-white/30 dark:bg-white/25 backdrop-blur-sm flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<Sparkles
className="absolute -top-1 -right-1 w-4 h-4 text-yellow-300 dark:text-yellow-200 animate-spin"
style={{ animationDuration: '3s' }}
/>
</div>

<div className="flex items-baseline gap-2">
<span className="text-xl">{getEmoji(online)}</span>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-black text-white dark:text-yellow-100 drop-shadow-sm">
{online}
</span>
<span className="text-base font-semibold text-white/95 dark:text-white/90 whitespace-nowrap">
{getText(online)}
</span>
</div>
</div>

<div className="ml-1 flex-shrink-0">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-300 dark:bg-purple-200 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-purple-400 dark:bg-purple-300" />
</span>
</div>

<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/25 dark:bg-white/20 rounded-b-2xl overflow-hidden">
<div
className="h-full bg-white/60 dark:bg-white/50"
style={{ animation: 'shrink 8s linear forwards' }}
/>
</div>
</div>

<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" />
<div
className="absolute -top-2 right-8 w-1 h-1 rounded-full bg-purple-400/60 dark:bg-purple-300/70 animate-ping"
style={{ animationDelay: '0.5s' }}
/>
</div>
</div>
);
}