Skip to content

Commit 3230af6

Browse files
ariefgpevereq
andauthored
feat(analytics): implement unique daily item view tracking (PR 1 of 3) (#440)
* feat(analytics): implement unique daily item view tracking - Add item_views table with daily deduplication constraint - Add bot detection utility for filtering crawlers - Add recordItemView() query with ON CONFLICT DO NOTHING - Add POST /api/items/[slug]/views endpoint - Bot detection via User-Agent - Owner exclusion when authenticated - Cookie-based viewer_id (365 day TTL) - Returns 503 when database not configured * feat(tracking): add client-side item view tracker - Create ItemViewTracker client component - Triggers POST /api/items/{slug}/views once on mount - Uses keepalive: true for reliable tracking - Errors are swallowed (best-effort) - Add tracker to item detail page * refactor(analytics): centralize viewer cookie constants * feat(dashboard): integrate view tracking into client dashboard stats - Import view query functions from item-view.queries.ts - Add view queries to Promise.all for parallel execution: - getTotalViewsCount, getRecentViewsCount - getDailyViewsData, getViewsPerItem - Update engagementChartData to display actual totalViews - Update mapTopItems() to include viewsPerItem parameter - Add injectViewsIntoActivityData() helper for activity chart - Set viewsAvailable: true in getStats() and getEmptyStats() - Update return values: totalViews, recentViews, activityChartDataWithViews * feat(submissions): show real view counts in client submission list - Import and use getViewsPerItem() in ClientItemRepository - Fetch views in findByUserPaginated(), findByIdForUser(), findDeletedByUser() - Update SubmissionItem to always show views (not just approved items) - Keep likes display conditional for approved items only * style(views): move owner check before cookie handling - Reorder steps to check owner exclusion before getting/creating viewer cookie - Prevents unnecessary cookie operations for owner requests * fix(analytics): use UTC methods for date calculation in view queries * fix issue on migrations --------- Co-authored-by: Ruslan Konviser <[email protected]>
1 parent 175ac38 commit 3230af6

File tree

16 files changed

+4887
-40
lines changed

16 files changed

+4887
-40
lines changed

app/[locale]/items/[slug]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getTranslations } from 'next-intl/server';
55
import { ItemDetail } from '@/components/item-detail';
66
import { ServerItemContent } from '@/components/item-detail/server-item-content';
77
import { Container } from '@/components/ui/container';
8+
import { ItemViewTracker } from '@/components/tracking/item-view-tracker';
89
import { Metadata } from 'next';
910
import { siteConfig } from '@/lib/config';
1011
import { cleanUrl } from '@/lib/utils/url-cleaner';
@@ -211,6 +212,7 @@ export default async function ItemDetails({ params }: { params: Promise<{ slug:
211212

212213
return (
213214
<Container maxWidth="7xl" padding="default" useGlobalWidth>
215+
<ItemViewTracker slug={slug} />
214216
<ItemDetail meta={metaWithVideo} renderedContent={renderedContent} categoryName={categoryName} />
215217
</Container>
216218
);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { cookies } from 'next/headers';
3+
import { auth } from '@/lib/auth';
4+
import { checkDatabaseAvailability } from '@/lib/utils/database-check';
5+
import { isBot } from '@/lib/utils/bot-detection';
6+
import { recordItemView } from '@/lib/db/queries/item-view.queries';
7+
import { ItemRepository } from '@/lib/repositories/item.repository';
8+
import { VIEWER_COOKIE_NAME, VIEWER_COOKIE_MAX_AGE } from '@/lib/constants/analytics';
9+
10+
type RouteParams = { params: Promise<{ slug: string }> };
11+
12+
/**
13+
* POST /api/items/[slug]/views
14+
*
15+
* Records a unique daily view for an item.
16+
*
17+
* Flow:
18+
* 1. Check database availability
19+
* 2. Detect and reject bots
20+
* 3. Exclude owner views (if authenticated)
21+
* 4. Get or create viewer ID from cookie
22+
* 5. Record view with daily deduplication
23+
*
24+
* Response:
25+
* - { success: true, counted: true } - New view recorded
26+
* - { success: true, counted: false } - Duplicate view (same day)
27+
* - { success: true, counted: false, reason: "bot" } - Bot detected
28+
* - { success: true, counted: false, reason: "owner" } - Owner viewing own item
29+
*/
30+
export async function POST(request: NextRequest, { params }: RouteParams) {
31+
try {
32+
// 1. Database availability check
33+
const dbCheck = checkDatabaseAvailability();
34+
if (dbCheck) return dbCheck;
35+
36+
const { slug } = await params;
37+
38+
// 2. Bot detection
39+
const userAgent = request.headers.get('user-agent') || '';
40+
if (isBot(userAgent)) {
41+
return NextResponse.json({ success: true, counted: false, reason: 'bot' });
42+
}
43+
44+
// 3. Owner exclusion (if authenticated) - check before cookie handling
45+
const session = await auth();
46+
if (session?.user?.id) {
47+
const itemRepository = new ItemRepository();
48+
const item = await itemRepository.findBySlug(slug);
49+
if (item?.submitted_by === session.user.id) {
50+
return NextResponse.json({ success: true, counted: false, reason: 'owner' });
51+
}
52+
}
53+
54+
// 4. Get or create viewer ID from cookie
55+
const cookieStore = await cookies();
56+
let viewerId = cookieStore.get(VIEWER_COOKIE_NAME)?.value;
57+
const isNewViewer = !viewerId;
58+
59+
if (!viewerId) {
60+
viewerId = crypto.randomUUID();
61+
}
62+
63+
// 5. Record view with daily deduplication
64+
const viewedDateUtc = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
65+
const counted = await recordItemView({
66+
itemId: slug,
67+
viewerId,
68+
viewedDateUtc
69+
});
70+
71+
// 6. Set cookie if new viewer
72+
if (isNewViewer) {
73+
cookieStore.set(VIEWER_COOKIE_NAME, viewerId, {
74+
httpOnly: true,
75+
secure: process.env.NODE_ENV === 'production',
76+
sameSite: 'lax',
77+
maxAge: VIEWER_COOKIE_MAX_AGE,
78+
path: '/'
79+
});
80+
}
81+
82+
return NextResponse.json({ success: true, counted });
83+
} catch (error) {
84+
if (process.env.NODE_ENV === 'development') {
85+
console.error('Error recording item view:', error);
86+
}
87+
return NextResponse.json({ success: false, error: 'Failed to record view' }, { status: 500 });
88+
}
89+
}

components/submissions/submission-item.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,15 @@ export function SubmissionItem({
137137
{t('SUBMITTED')}: {formatDate(submission.submittedAt)}
138138
</span>
139139
)}
140-
{submission.status === "approved" && submission.views > 0 && (
141-
<>
142-
<span className="flex items-center gap-1">
143-
<FiEye className="w-3 h-3" />
144-
{t('VIEWS_COUNT', { count: submission.views })}
145-
</span>
146-
<span className="flex items-center gap-1">
147-
<FiTrendingUp className="w-3 h-3" />
148-
{t('LIKES_COUNT', { count: submission.likes })}
149-
</span>
150-
</>
140+
<span className="flex items-center gap-1">
141+
<FiEye className="w-3 h-3" />
142+
{t('VIEWS_COUNT', { count: submission.views })}
143+
</span>
144+
{submission.status === "approved" && submission.likes > 0 && (
145+
<span className="flex items-center gap-1">
146+
<FiTrendingUp className="w-3 h-3" />
147+
{t('LIKES_COUNT', { count: submission.likes })}
148+
</span>
151149
)}
152150
</div>
153151
{submission.status === "rejected" && submission.rejectionReason && (
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client';
2+
3+
import { useEffect } from 'react';
4+
5+
interface ItemViewTrackerProps {
6+
slug: string;
7+
}
8+
9+
/**
10+
* Client-side component that tracks item page views.
11+
*
12+
* Records a unique daily view by calling the views API endpoint.
13+
* Runs once on mount, does not affect page performance or reliability.
14+
* Errors are swallowed (best-effort tracking).
15+
*/
16+
export function ItemViewTracker({ slug }: ItemViewTrackerProps) {
17+
useEffect(() => {
18+
fetch(`/api/items/${slug}/views`, {
19+
method: 'POST',
20+
keepalive: true
21+
}).catch(() => {
22+
// Best-effort tracking - swallow errors silently
23+
});
24+
}, [slug]);
25+
26+
return null;
27+
}

lib/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,11 @@ export {
104104
SponsorAdPricing,
105105
type ExceptionTrackingProvider,
106106
} from './constants/payment';
107+
108+
// ============================================
109+
// ANALYTICS (re-exported from constants/analytics)
110+
// ============================================
111+
export {
112+
VIEWER_COOKIE_NAME,
113+
VIEWER_COOKIE_MAX_AGE,
114+
} from './constants/analytics';

lib/constants/analytics.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Analytics-related constants.
3+
* This file contains constants for analytics tracking, including
4+
* viewer identification and session tracking.
5+
*/
6+
7+
// ============================================
8+
// VIEWER TRACKING
9+
// ============================================
10+
11+
/**
12+
* Cookie name for storing the anonymous viewer ID.
13+
* Used for tracking unique daily views without requiring authentication.
14+
*/
15+
export const VIEWER_COOKIE_NAME = 'ever_viewer_id';
16+
17+
/**
18+
* Cookie max age in seconds (365 days).
19+
* Long-lived to maintain consistent viewer identification across sessions.
20+
*/
21+
export const VIEWER_COOKIE_MAX_AGE = 365 * 24 * 60 * 60;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CREATE TABLE "item_views" (
2+
"id" text PRIMARY KEY NOT NULL,
3+
"item_id" text NOT NULL,
4+
"viewer_id" text NOT NULL,
5+
"viewed_date_utc" text NOT NULL,
6+
"viewed_at" timestamp with time zone DEFAULT now() NOT NULL
7+
);
8+
--> statement-breakpoint
9+
ALTER TABLE "subscriptions" DROP CONSTRAINT "auto_renewal_check";--> statement-breakpoint
10+
CREATE UNIQUE INDEX "item_views_unique_daily_idx" ON "item_views" USING btree ("item_id","viewer_id","viewed_date_utc");--> statement-breakpoint
11+
CREATE INDEX "item_views_item_date_idx" ON "item_views" USING btree ("item_id","viewed_date_utc");--> statement-breakpoint
12+
ALTER TABLE "subscriptions" ADD CONSTRAINT "auto_renewal_check" CHECK (NOT ("subscriptions"."auto_renewal" AND "subscriptions"."cancel_at_period_end"));
File renamed without changes.

lib/db/migrations/meta/0022_snapshot.json

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"id": "20fd1bc9-d40f-4309-9572-a947d06e908e",
2+
"id": "76c7e06e-520c-412d-b905-16ca448ef88b",
33
"prevId": "e8d0d731-ca24-4221-89fe-0cd3607c173f",
44
"version": "7",
55
"dialect": "postgresql",
@@ -469,19 +469,6 @@
469469
"notNull": false,
470470
"default": "'en'"
471471
},
472-
"country": {
473-
"name": "country",
474-
"type": "text",
475-
"primaryKey": false,
476-
"notNull": false
477-
},
478-
"currency": {
479-
"name": "currency",
480-
"type": "text",
481-
"primaryKey": false,
482-
"notNull": false,
483-
"default": "'USD'"
484-
},
485472
"two_factor_enabled": {
486473
"name": "two_factor_enabled",
487474
"type": "boolean",
@@ -1341,6 +1328,99 @@
13411328
"checkConstraints": {},
13421329
"isRLSEnabled": false
13431330
},
1331+
"public.item_views": {
1332+
"name": "item_views",
1333+
"schema": "",
1334+
"columns": {
1335+
"id": {
1336+
"name": "id",
1337+
"type": "text",
1338+
"primaryKey": true,
1339+
"notNull": true
1340+
},
1341+
"item_id": {
1342+
"name": "item_id",
1343+
"type": "text",
1344+
"primaryKey": false,
1345+
"notNull": true
1346+
},
1347+
"viewer_id": {
1348+
"name": "viewer_id",
1349+
"type": "text",
1350+
"primaryKey": false,
1351+
"notNull": true
1352+
},
1353+
"viewed_date_utc": {
1354+
"name": "viewed_date_utc",
1355+
"type": "text",
1356+
"primaryKey": false,
1357+
"notNull": true
1358+
},
1359+
"viewed_at": {
1360+
"name": "viewed_at",
1361+
"type": "timestamp with time zone",
1362+
"primaryKey": false,
1363+
"notNull": true,
1364+
"default": "now()"
1365+
}
1366+
},
1367+
"indexes": {
1368+
"item_views_unique_daily_idx": {
1369+
"name": "item_views_unique_daily_idx",
1370+
"columns": [
1371+
{
1372+
"expression": "item_id",
1373+
"isExpression": false,
1374+
"asc": true,
1375+
"nulls": "last"
1376+
},
1377+
{
1378+
"expression": "viewer_id",
1379+
"isExpression": false,
1380+
"asc": true,
1381+
"nulls": "last"
1382+
},
1383+
{
1384+
"expression": "viewed_date_utc",
1385+
"isExpression": false,
1386+
"asc": true,
1387+
"nulls": "last"
1388+
}
1389+
],
1390+
"isUnique": true,
1391+
"concurrently": false,
1392+
"method": "btree",
1393+
"with": {}
1394+
},
1395+
"item_views_item_date_idx": {
1396+
"name": "item_views_item_date_idx",
1397+
"columns": [
1398+
{
1399+
"expression": "item_id",
1400+
"isExpression": false,
1401+
"asc": true,
1402+
"nulls": "last"
1403+
},
1404+
{
1405+
"expression": "viewed_date_utc",
1406+
"isExpression": false,
1407+
"asc": true,
1408+
"nulls": "last"
1409+
}
1410+
],
1411+
"isUnique": false,
1412+
"concurrently": false,
1413+
"method": "btree",
1414+
"with": {}
1415+
}
1416+
},
1417+
"foreignKeys": {},
1418+
"compositePrimaryKeys": {},
1419+
"uniqueConstraints": {},
1420+
"policies": {},
1421+
"checkConstraints": {},
1422+
"isRLSEnabled": false
1423+
},
13441424
"public.items_companies": {
13451425
"name": "items_companies",
13461426
"schema": "",

0 commit comments

Comments
 (0)