Skip to content

Commit e3ddcb6

Browse files
committed
feat: tack download and share counts in client
1 parent e207f54 commit e3ddcb6

4 files changed

Lines changed: 229 additions & 0 deletions

File tree

client/src/components/ui/MemeCard.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ClipboardCheck, Download, PencilLine, Share2 } from "lucide-react";
55

66
import { Card } from "@/components/ui/card";
77
import { getMemeUrl } from "@/functions/memeUrl";
8+
import { trackDownload, trackShare } from "@/functions/trackEngagement";
89
import { Link } from "@/i18n/navigation";
910
import { sendEvent } from "@/utils/googleAnalytics";
1011
import { cn } from "@/utils/tailwind";
@@ -66,7 +67,17 @@ export default function MemeCard({
6667
const handleShare: MouseEventHandler = (e) => {
6768
e.stopPropagation();
6869
e.preventDefault();
70+
71+
// Track share engagement (non-blocking)
72+
trackShare(meme.id).catch((error) => {
73+
// Silently handle tracking errors - don't block share
74+
console.warn("Failed to track share:", error);
75+
});
76+
77+
// Send analytics event
6978
logShareEvent(meme);
79+
80+
// Perform the actual share action (copy to clipboard)
7081
const shareLink = `https://qasrelmemez.com/meme/${meme.id}`;
7182
navigator.clipboard.writeText(shareLink).then(() => {
7283
setShareLogo(ClipboardIcon);
@@ -80,6 +91,12 @@ export default function MemeCard({
8091
if (isDownloading) return;
8192
setIsDownloading(true);
8293

94+
// Track download engagement (non-blocking)
95+
trackDownload(meme.id).catch((error) => {
96+
// Silently handle tracking errors - don't block download
97+
console.warn("Failed to track download:", error);
98+
});
99+
83100
// send analytics event
84101
logDownloadEvent(meme);
85102
try {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* API client functions for tracking meme engagement (downloads and shares).
3+
* Implements session-based deduplication to prevent duplicate tracking.
4+
*/
5+
6+
import { isActionTracked, markActionTracked, type EngagementAction } from '@/utils/sessionTracking';
7+
8+
/**
9+
* Tracks a meme download by sending a POST request to the tracking endpoint.
10+
* Checks session storage before sending to prevent duplicate tracking.
11+
* Errors are handled silently to avoid blocking the user's download action.
12+
*
13+
* @param memeId - The UUID of the meme being downloaded
14+
*
15+
* @example
16+
* // Call when user clicks download button
17+
* await trackDownload(meme.id);
18+
* // Then proceed with actual download
19+
*/
20+
export async function trackDownload(memeId: string): Promise<void> {
21+
await trackEngagement(memeId, 'download');
22+
}
23+
24+
/**
25+
* Tracks a meme share by sending a POST request to the tracking endpoint.
26+
* Checks session storage before sending to prevent duplicate tracking.
27+
* Errors are handled silently to avoid blocking the user's share action.
28+
*
29+
* @param memeId - The UUID of the meme being shared
30+
*
31+
* @example
32+
* // Call when user clicks share button
33+
* await trackShare(meme.id);
34+
* // Then proceed with actual share action
35+
*/
36+
export async function trackShare(memeId: string): Promise<void> {
37+
await trackEngagement(memeId, 'share');
38+
}
39+
40+
/**
41+
* Internal function to track engagement actions.
42+
* Implements the core logic for deduplication and API calls.
43+
*/
44+
async function trackEngagement(memeId: string, action: EngagementAction): Promise<void> {
45+
// Check if this action has already been tracked in this session
46+
if (isActionTracked(memeId, action)) {
47+
return;
48+
}
49+
50+
try {
51+
// Construct the API endpoint URL
52+
const url = new URL(
53+
`/api/memes/${memeId}/${action}`,
54+
process.env.NEXT_PUBLIC_API_HOST
55+
);
56+
57+
// Send POST request to tracking endpoint
58+
const response = await fetch(url.toString(), {
59+
method: 'POST',
60+
headers: {
61+
'Content-Type': 'application/json',
62+
},
63+
});
64+
65+
// Only mark as tracked if the request was successful
66+
if (response.ok) {
67+
markActionTracked(memeId, action);
68+
} else {
69+
// Log error for debugging but don't throw
70+
console.warn(`Failed to track ${action} for meme ${memeId}: ${response.status}`);
71+
}
72+
} catch (error) {
73+
// Handle network errors silently - don't block user action
74+
console.warn(`Error tracking ${action} for meme ${memeId}:`, error);
75+
}
76+
}

client/src/types/Meme.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export interface Meme {
77
tags: string[];
88
name: string;
99
dimensions: number[];
10+
download_count?: number;
11+
share_count?: number;
1012
}
1113
export interface MemesResponse {
1214
memes: Meme[];
@@ -33,6 +35,8 @@ const memeSchema: JSONSchemaType<Meme> = {
3335
type: "array",
3436
items: { type: "number" },
3537
},
38+
download_count: { type: "number", nullable: true },
39+
share_count: { type: "number", nullable: true },
3640
},
3741
required: ["id", "media_url", "media_type", "tags", "name", "dimensions"],
3842
additionalProperties: false,
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Session storage utilities for tracking meme engagement actions.
3+
* Prevents duplicate tracking of downloads and shares within the same browser session.
4+
*/
5+
6+
export type EngagementAction = 'download' | 'share';
7+
8+
// Session storage keys for tracking engagement
9+
const STORAGE_KEYS = {
10+
DOWNLOADED_MEMES: 'memedb_downloaded_memes',
11+
SHARED_MEMES: 'memedb_shared_memes',
12+
} as const;
13+
14+
/**
15+
* Checks if session storage is available.
16+
* Returns false in private browsing mode or when storage is disabled.
17+
*/
18+
function isSessionStorageAvailable(): boolean {
19+
try {
20+
const testKey = '__storage_test__';
21+
sessionStorage.setItem(testKey, 'test');
22+
sessionStorage.removeItem(testKey);
23+
return true;
24+
} catch (e) {
25+
return false;
26+
}
27+
}
28+
29+
/**
30+
* Gets the session storage key for a given action type.
31+
*/
32+
function getStorageKey(action: EngagementAction): string {
33+
return action === 'download'
34+
? STORAGE_KEYS.DOWNLOADED_MEMES
35+
: STORAGE_KEYS.SHARED_MEMES;
36+
}
37+
38+
/**
39+
* Retrieves the set of tracked meme IDs for a given action from session storage.
40+
* Returns an empty set if storage is unavailable or data is corrupted.
41+
*/
42+
function getTrackedMemes(action: EngagementAction): Set<string> {
43+
if (!isSessionStorageAvailable()) {
44+
return new Set();
45+
}
46+
47+
try {
48+
const key = getStorageKey(action);
49+
const stored = sessionStorage.getItem(key);
50+
51+
if (!stored) {
52+
return new Set();
53+
}
54+
55+
const parsed = JSON.parse(stored);
56+
57+
// Validate that parsed data is an array
58+
if (!Array.isArray(parsed)) {
59+
return new Set();
60+
}
61+
62+
return new Set(parsed);
63+
} catch (e) {
64+
// Handle JSON parse errors or other exceptions gracefully
65+
console.warn(`Failed to retrieve tracked memes for ${action}:`, e);
66+
return new Set();
67+
}
68+
}
69+
70+
/**
71+
* Saves the set of tracked meme IDs for a given action to session storage.
72+
* Fails silently if storage is unavailable.
73+
*/
74+
function saveTrackedMemes(action: EngagementAction, memeIds: Set<string>): void {
75+
if (!isSessionStorageAvailable()) {
76+
return;
77+
}
78+
79+
try {
80+
const key = getStorageKey(action);
81+
const array = Array.from(memeIds);
82+
sessionStorage.setItem(key, JSON.stringify(array));
83+
} catch (e) {
84+
// Handle quota exceeded or other storage errors gracefully
85+
console.warn(`Failed to save tracked memes for ${action}:`, e);
86+
87+
// If quota exceeded, try to clear old entries and retry
88+
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
89+
try {
90+
const key = getStorageKey(action);
91+
sessionStorage.removeItem(key);
92+
sessionStorage.setItem(key, JSON.stringify(Array.from(memeIds)));
93+
} catch (retryError) {
94+
// If retry fails, fail silently
95+
console.warn(`Retry failed for ${action}:`, retryError);
96+
}
97+
}
98+
}
99+
}
100+
101+
/**
102+
* Checks if a meme action has already been tracked in the current session.
103+
*
104+
* @param memeId - The UUID of the meme
105+
* @param action - The engagement action type ('download' or 'share')
106+
* @returns true if the action has been tracked, false otherwise
107+
*
108+
* @example
109+
* if (!isActionTracked(meme.id, 'download')) {
110+
* // Track the download
111+
* }
112+
*/
113+
export function isActionTracked(memeId: string, action: EngagementAction): boolean {
114+
const trackedMemes = getTrackedMemes(action);
115+
return trackedMemes.has(memeId);
116+
}
117+
118+
/**
119+
* Marks a meme action as tracked in the current session.
120+
* This prevents duplicate tracking of the same action within the session.
121+
*
122+
* @param memeId - The UUID of the meme
123+
* @param action - The engagement action type ('download' or 'share')
124+
*
125+
* @example
126+
* markActionTracked(meme.id, 'download');
127+
*/
128+
export function markActionTracked(memeId: string, action: EngagementAction): void {
129+
const trackedMemes = getTrackedMemes(action);
130+
trackedMemes.add(memeId);
131+
saveTrackedMemes(action, trackedMemes);
132+
}

0 commit comments

Comments
 (0)