feat(auth): update analytics tracking for authentication and registration components

This commit is contained in:
Fatih Kadir Akın
2025-12-16 00:26:58 +03:00
parent 68e00f1054
commit 6de92258ed
24 changed files with 731 additions and 21 deletions
+14 -7
View File
@@ -43,7 +43,7 @@ export default function PrivacyPage() {
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>Prompts you create and share</li>
<li>Categories and tags you assign to prompts</li>
<li>Basic usage analytics to improve the service</li>
<li>Analytics data through Google Analytics to understand usage patterns (can be disabled on self-hosted instances)</li>
</ul>
</section>
@@ -69,23 +69,30 @@ export default function PrivacyPage() {
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold">Cookies</h2>
<h2 className="text-lg font-semibold">Analytics & Cookies</h2>
<p className="text-muted-foreground">
We use essential cookies for authentication and session management. We may use
analytics cookies to understand how the service is used.
We use essential cookies for authentication and session management. We use Google Analytics
to understand how the service is used, including tracking events such as prompt views, searches,
and interactions. On self-hosted instances, analytics can be disabled by not setting the
<code className="px-1 py-0.5 bg-muted rounded text-xs">GOOGLE_ANALYTICS_ID</code> environment variable.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-semibold">Your Rights</h2>
<h2 className="text-lg font-semibold">Your Rights & CC0 License</h2>
<p className="text-muted-foreground">
You can:
</p>
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>Access and update your account information</li>
<li>Delete your account and associated data</li>
<li>Export your prompts</li>
<li>Download any prompts directly from the platform (all prompts are CC0 and publicly available)</li>
</ul>
<p className="text-muted-foreground mt-3">
<strong>Important:</strong> Because all prompts are released under the CC0 license and belong to
the public domain, they cannot be deleted or removed once published. This is by design—CC0 content
is free for anyone to use, modify, and distribute without restriction. Account deletion does not
remove prompts you have contributed, as they are part of the public commons.
</p>
</section>
<section className="space-y-3">
+3
View File
@@ -19,6 +19,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { toast } from "sonner";
import { analyticsAuth } from "@/lib/analytics";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
@@ -51,10 +52,12 @@ export function LoginForm() {
});
if (result?.error) {
analyticsAuth.loginFailed("credentials");
toast.error(t("invalidCredentials"));
return;
}
analyticsAuth.login("credentials");
toast.success(t("loginSuccess"));
router.push("/");
router.refresh();
+2
View File
@@ -5,6 +5,7 @@ import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { analyticsAuth } from "@/lib/analytics";
interface OAuthButtonProps {
provider: string;
@@ -38,6 +39,7 @@ export function OAuthButton({ provider, providerName }: OAuthButtonProps) {
const handleSignIn = async () => {
setIsLoading(true);
analyticsAuth.oauthStart(provider);
try {
await signIn(provider, { callbackUrl: "/" });
} catch (error) {
+5
View File
@@ -18,6 +18,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { toast } from "sonner";
import { analyticsAuth } from "@/lib/analytics";
const registerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -68,15 +69,19 @@ export function RegisterForm() {
if (!response.ok) {
if (result.error === "email_taken") {
analyticsAuth.registerFailed("email_taken");
toast.error(t("emailTaken"));
} else if (result.error === "username_taken") {
analyticsAuth.registerFailed("username_taken");
toast.error(t("usernameTaken"));
} else {
analyticsAuth.registerFailed(result.error);
toast.error(result.message || t("registrationFailed"));
}
return;
}
analyticsAuth.register();
toast.success(t("registerSuccess"));
router.push("/login");
} catch {
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Bell, BellOff, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { analyticsCategory } from "@/lib/analytics";
interface SubscribeButtonProps {
categoryId: string;
@@ -34,6 +35,13 @@ export function SubscribeButton({ categoryId, categoryName, initialSubscribed, i
}
setIsSubscribed(!isSubscribed);
if (isSubscribed) {
analyticsCategory.unsubscribe(categoryId, categoryName);
} else {
analyticsCategory.subscribe(categoryId, categoryName);
}
toast.success(
isSubscribed
? t("unsubscribedFrom", { name: categoryName })
+18 -4
View File
@@ -34,6 +34,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { NotificationBell } from "@/components/layout/notification-bell";
import { setLocale } from "@/lib/i18n/client";
import { useBranding } from "@/components/providers/branding-provider";
import { analyticsAuth, analyticsNav, analyticsSettings } from "@/lib/analytics";
const languages = [
{ code: "en", name: "English" },
@@ -246,7 +247,11 @@ export function Header({ authProvider = "credentials", allowRegistration = true
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
onClick={() => {
const newTheme = theme === "dark" ? "light" : "dark";
analyticsSettings.changeTheme(newTheme);
setTheme(newTheme);
}}
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
@@ -309,7 +314,10 @@ export function Header({ authProvider = "credentials", allowRegistration = true
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLocale(lang.code)}
onClick={() => {
analyticsSettings.changeLanguage(lang.code);
setLocale(lang.code);
}}
>
{lang.name}
</DropdownMenuItem>
@@ -317,7 +325,10 @@ export function Header({ authProvider = "credentials", allowRegistration = true
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/" })}>
<DropdownMenuItem onClick={() => {
analyticsAuth.logout();
signOut({ callbackUrl: "/" });
}}>
<LogOut className="mr-2 h-4 w-4" />
{t("nav.logout")}
</DropdownMenuItem>
@@ -336,7 +347,10 @@ export function Header({ authProvider = "credentials", allowRegistration = true
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLocale(lang.code)}
onClick={() => {
analyticsSettings.changeLanguage(lang.code);
setLocale(lang.code);
}}
>
{lang.name}
</DropdownMenuItem>
+2
View File
@@ -11,6 +11,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { analyticsMcp } from "@/lib/analytics";
// MCP Logo component
function McpIcon({ className }: { className?: string }) {
@@ -87,6 +88,7 @@ export function McpServerPopup({
const handleCopy = async () => {
await navigator.clipboard.writeText(configJson);
analyticsMcp.copyCommand("config_json");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
@@ -7,6 +7,7 @@ import { Check, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { analyticsPrompt } from "@/lib/analytics";
interface ChangeRequestActionsProps {
changeRequestId: string;
@@ -40,6 +41,7 @@ export function ChangeRequestActions({ changeRequestId, promptId }: ChangeReques
throw new Error(error.message || "Failed to update change request");
}
analyticsPrompt.changeRequest(promptId, selectedAction === "approve" ? "approve" : "dismiss");
toast.success(selectedAction === "approve" ? t("approvedSuccess") : t("rejectedSuccess"));
router.refresh();
} catch (error) {
@@ -13,6 +13,7 @@ import { DiffView } from "@/components/ui/diff-view";
import { CodeEditor } from "@/components/ui/code-editor";
import { VariableToolbar } from "@/components/prompts/variable-toolbar";
import { toast } from "sonner";
import { analyticsPrompt } from "@/lib/analytics";
interface ChangeRequestFormProps {
promptId: string;
@@ -79,6 +80,7 @@ export function ChangeRequestForm({ promptId, currentContent, currentTitle, prom
}
const result = await response.json();
analyticsPrompt.changeRequest(promptId, "create");
toast.success(t("created"));
router.push(`/prompts/${promptId}/changes/${result.id}`);
router.refresh();
+4 -1
View File
@@ -5,12 +5,14 @@ import { useTranslations } from "next-intl";
import { Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { analyticsPrompt } from "@/lib/analytics";
interface CopyButtonProps {
content: string;
promptId?: string;
}
export function CopyButton({ content }: CopyButtonProps) {
export function CopyButton({ content, promptId }: CopyButtonProps) {
const t = useTranslations("common");
const [copied, setCopied] = useState(false);
@@ -18,6 +20,7 @@ export function CopyButton({ content }: CopyButtonProps) {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
analyticsPrompt.copy(promptId);
toast.success(t("copied"));
setTimeout(() => setCopied(false), 2000);
} catch {
@@ -17,6 +17,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { analyticsPrompt } from "@/lib/analytics";
interface DismissChangeRequestButtonProps {
changeRequestId: string;
@@ -43,6 +44,7 @@ export function DismissChangeRequestButton({ changeRequestId, promptId }: Dismis
throw new Error(error.message || "Failed to dismiss change request");
}
analyticsPrompt.changeRequest(promptId, "dismiss");
toast.success(t("dismissed"));
router.push(`/prompts/${promptId}`);
router.refresh();
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { analyticsPrompt } from "@/lib/analytics";
interface FeaturePromptButtonProps {
promptId: string;
@@ -31,6 +32,11 @@ export function FeaturePromptButton({
if (response.ok) {
const data = await response.json();
setIsFeatured(data.isFeatured);
if (data.isFeatured) {
analyticsPrompt.feature(promptId);
} else {
analyticsPrompt.unfeature(promptId);
}
}
} catch (error) {
console.error("Error toggling featured status:", error);
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { Bot, ArrowUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { analyticsHero } from "@/lib/analytics";
const TYPING_SPEED = 50; // ms per character
const PAUSE_BETWEEN_PROMPTS = 2000; // ms to pause after completing a prompt
@@ -78,6 +79,7 @@ export function HeroPromptInput() {
setIsFocused(true);
setIsAnimating(false);
clearAnimation();
analyticsHero.focusInput();
// Transfer the animated text to the actual input value
setInputValue(displayText);
setDisplayText("");
@@ -95,6 +97,7 @@ export function HeroPromptInput() {
e?.preventDefault();
const value = inputValue.trim();
if (value) {
analyticsHero.submitPromptIdea(value);
router.push(`/prompts/new?prompt=${encodeURIComponent(value)}`);
}
};
@@ -113,6 +116,7 @@ export function HeroPromptInput() {
clearAnimation();
setInputValue("");
setDisplayText("");
analyticsHero.clickAnimatedPrompt();
// Focus the textarea
setTimeout(() => {
textareaRef.current?.focus();
+6
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Pin, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { analyticsPrompt } from "@/lib/analytics";
interface PinButtonProps {
promptId: string;
@@ -32,6 +33,11 @@ export function PinButton({ promptId, initialPinned, iconOnly = false }: PinButt
}
setIsPinned(data.pinned);
if (data.pinned) {
analyticsPrompt.pin(promptId);
} else {
analyticsPrompt.unpin(promptId);
}
toast.success(data.pinned ? t("pinned") : t("unpinned"));
} catch {
toast.error(t("pinFailed"));
+31 -5
View File
@@ -16,6 +16,7 @@ import {
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { X, Sparkles, Search } from "lucide-react";
import { analyticsSearch } from "@/lib/analytics";
interface PromptFiltersProps {
categories: Array<{
@@ -69,6 +70,7 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
};
const clearFilters = () => {
analyticsSearch.clearFilters();
router.push("/prompts");
};
@@ -105,6 +107,9 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
}
// Debounce the actual navigation
debounceRef.current = setTimeout(() => {
if (value) {
analyticsSearch.search(value, currentFilters.ai === "1");
}
updateFilter("q", value || null);
}, 300);
}}
@@ -121,7 +126,10 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
<Switch
id="ai-search"
checked={currentFilters.ai === "1"}
onCheckedChange={(checked) => updateFilter("ai", checked ? "1" : null)}
onCheckedChange={(checked) => {
analyticsSearch.aiSearchToggle(checked);
updateFilter("ai", checked ? "1" : null);
}}
/>
</div>
)}
@@ -131,7 +139,12 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
<Label className="text-xs">{t("prompts.promptType")}</Label>
<Select
value={currentFilters.type || "all"}
onValueChange={(value) => updateFilter("type", value === "all" ? null : value)}
onValueChange={(value) => {
if (value !== "all") {
analyticsSearch.filter("type", value);
}
updateFilter("type", value === "all" ? null : value);
}}
>
<SelectTrigger className="h-8 text-sm w-full">
<SelectValue placeholder={t("common.all")} />
@@ -151,7 +164,12 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
<Label className="text-xs">{t("prompts.promptCategory")}</Label>
<Select
value={currentFilters.category || "all"}
onValueChange={(value) => updateFilter("category", value === "all" ? null : value)}
onValueChange={(value) => {
if (value !== "all") {
analyticsSearch.filter("category", value);
}
updateFilter("category", value === "all" ? null : value);
}}
>
<SelectTrigger className="h-8 text-sm w-full">
<SelectValue placeholder={t("common.all")} />
@@ -186,7 +204,10 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
<Label className="text-xs">{t("search.sortBy")}</Label>
<Select
value={currentFilters.sort || "newest"}
onValueChange={(value) => updateFilter("sort", value === "newest" ? null : value)}
onValueChange={(value) => {
analyticsSearch.sort(value);
updateFilter("sort", value === "newest" ? null : value);
}}
>
<SelectTrigger className="h-8 text-sm w-full">
<SelectValue />
@@ -222,7 +243,12 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
? { backgroundColor: tag.color, color: "white", borderColor: tag.color }
: { borderColor: tag.color + "40", color: tag.color }
}
onClick={() => updateFilter("tag", currentFilters.tag === tag.slug ? null : tag.slug)}
onClick={() => {
if (currentFilters.tag !== tag.slug) {
analyticsSearch.filter("tag", tag.slug);
}
updateFilter("tag", currentFilters.tag === tag.slug ? null : tag.slug);
}}
>
{tag.name}
</button>
+6
View File
@@ -36,6 +36,7 @@ import { Badge } from "@/components/ui/badge";
import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor";
import { toast } from "sonner";
import { prettifyJson } from "@/lib/format";
import { analyticsPrompt } from "@/lib/analytics";
interface MediaFieldProps {
form: ReturnType<typeof useForm<PromptFormValues>>;
@@ -396,6 +397,11 @@ export function PromptForm({ categories, tags, initialData, initialContributors
}
const result = await response.json();
if (isEdit) {
analyticsPrompt.edit(promptId!);
} else {
analyticsPrompt.create(data.type);
}
toast.success(isEdit ? t("promptUpdated") : t("promptCreated"));
router.push(`/prompts/${result.id || promptId}`);
router.refresh();
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { RotateCcw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { analyticsPrompt } from "@/lib/analytics";
interface ReopenChangeRequestButtonProps {
changeRequestId: string;
@@ -33,6 +34,7 @@ export function ReopenChangeRequestButton({ changeRequestId, promptId }: ReopenC
throw new Error(error.message || "Failed to reopen change request");
}
analyticsPrompt.changeRequest(promptId, "reopen");
toast.success(t("reopenedSuccess"));
router.refresh();
} catch (error) {
@@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { analyticsPrompt } from "@/lib/analytics";
interface ReportPromptDialogProps {
promptId: string;
@@ -58,6 +59,7 @@ export function ReportPromptDialog({ promptId, isLoggedIn }: ReportPromptDialogP
throw new Error(data.error || "Failed to submit report");
}
analyticsPrompt.report(promptId, reason);
toast.success(t("reportSubmitted"));
setOpen(false);
setReason("");
+8 -2
View File
@@ -21,6 +21,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { analyticsPrompt } from "@/lib/analytics";
interface Platform {
id: string;
@@ -109,6 +110,7 @@ interface RunPromptButtonProps {
unfilledVariables?: UnfilledVariable[];
onVariablesFilled?: (values: Record<string, string>) => void;
getContentWithVariables?: (values: Record<string, string>) => string;
promptId?: string;
}
export function RunPromptButton({
@@ -118,7 +120,8 @@ export function RunPromptButton({
className,
unfilledVariables = [],
onVariablesFilled,
getContentWithVariables
getContentWithVariables,
promptId
}: RunPromptButtonProps) {
const t = useTranslations("prompts");
const tCommon = useTranslations("common");
@@ -157,8 +160,9 @@ export function RunPromptButton({
window.open(url, "_blank");
setPendingPlatform(null);
}
analyticsPrompt.run(promptId, pendingPlatform.name);
}
}, [variableValues, onVariablesFilled, pendingPlatform, getContentWithVariables, content]);
}, [variableValues, onVariablesFilled, pendingPlatform, getContentWithVariables, content, promptId]);
const handleRun = (platform: Platform, baseUrl: string) => {
// Check if there are unfilled variables (empty values)
@@ -174,9 +178,11 @@ export function RunPromptButton({
navigator.clipboard.writeText(content);
setPendingPlatform({ id: platform.id, name: platform.name, baseUrl, supportsQuerystring: platform.supportsQuerystring });
setDialogOpen(true);
analyticsPrompt.run(promptId, platform.name);
} else {
const url = buildUrl(platform.id, baseUrl, content);
window.open(url, "_blank");
analyticsPrompt.run(promptId, platform.name);
}
};
+4 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { Share2 } from "lucide-react";
import { analyticsPrompt } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -30,9 +31,10 @@ function HackerNewsIcon({ className }: { className?: string }) {
interface ShareDropdownProps {
title: string;
url?: string;
promptId?: string;
}
export function ShareDropdown({ title, url }: ShareDropdownProps) {
export function ShareDropdown({ title, url, promptId }: ShareDropdownProps) {
const handleShare = (platform: "twitter" | "hackernews") => {
const shareUrl = url || (typeof window !== "undefined" ? window.location.href : "");
const encodedUrl = encodeURIComponent(shareUrl);
@@ -48,6 +50,7 @@ export function ShareDropdown({ title, url }: ShareDropdownProps) {
}
window.open(targetUrl, "_blank", "noopener,noreferrer");
analyticsPrompt.share(promptId, platform);
};
return (
+7
View File
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { analyticsPrompt } from "@/lib/analytics";
interface UpvoteButtonProps {
promptId: string;
@@ -61,6 +62,12 @@ export function UpvoteButton({
const data = await response.json();
setIsVoted(data.voted);
setVoteCount(data.voteCount);
if (data.voted) {
analyticsPrompt.upvote(promptId);
} else {
analyticsPrompt.removeUpvote(promptId);
}
} catch {
toast.error(tCommon("error"));
} finally {
@@ -14,6 +14,7 @@ import {
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
import { analyticsPrompt } from "@/lib/analytics";
interface Variable {
name: string;
@@ -26,6 +27,7 @@ interface VariableFillModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "copy" | "run";
promptId?: string;
}
// Parse ${variablename:defaultvalue} or ${variablename} patterns
@@ -60,7 +62,8 @@ export function VariableFillModal({
content,
open,
onOpenChange,
mode
mode,
promptId
}: VariableFillModalProps) {
const t = useTranslations("common");
@@ -96,6 +99,8 @@ export function VariableFillModal({
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(getFinalContent());
analyticsPrompt.fillVariables(promptId);
analyticsPrompt.copy(promptId);
toast.success(t("copied"));
onOpenChange(false);
} catch {
+5
View File
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { analyticsProfile } from "@/lib/analytics";
const profileSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
@@ -74,6 +75,10 @@ export function ProfileForm({ user }: ProfileFormProps) {
// Trigger NextAuth session refresh - JWT callback will fetch updated data from DB
await update({});
analyticsProfile.updateProfile();
if (data.avatar !== user.avatar) {
analyticsProfile.updateAvatar();
}
toast.success(t("profileUpdated"));
router.refresh();
+582
View File
@@ -0,0 +1,582 @@
/**
* Google Analytics Event Tracking Utility
*
* Provides typed functions for tracking user interactions throughout the app.
* Events are only sent if GOOGLE_ANALYTICS_ID is configured.
*/
declare global {
interface Window {
gtag?: (
command: "event" | "config" | "js",
action: string,
params?: Record<string, unknown>
) => void;
}
}
type GTagEvent = {
action: string;
category: string;
label?: string;
value?: number;
[key: string]: unknown;
};
function trackEvent({ action, category, label, value, ...rest }: GTagEvent) {
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
...rest,
});
}
}
// ============================================================================
// Authentication Events
// ============================================================================
export const analyticsAuth = {
login: (method: "credentials" | "github" | "google" | "azure") => {
trackEvent({
action: "login",
category: "auth",
label: method,
});
},
loginFailed: (method: "credentials" | "github" | "google" | "azure") => {
trackEvent({
action: "login_failed",
category: "auth",
label: method,
});
},
register: () => {
trackEvent({
action: "register",
category: "auth",
});
},
registerFailed: (reason?: string) => {
trackEvent({
action: "register_failed",
category: "auth",
label: reason,
});
},
logout: () => {
trackEvent({
action: "logout",
category: "auth",
});
},
oauthStart: (provider: string) => {
trackEvent({
action: "oauth_start",
category: "auth",
label: provider,
});
},
};
// ============================================================================
// Prompt Events
// ============================================================================
export const analyticsPrompt = {
view: (promptId: string, promptTitle?: string) => {
trackEvent({
action: "view_prompt",
category: "prompt",
label: promptTitle,
prompt_id: promptId,
});
},
create: (promptType: string) => {
trackEvent({
action: "create_prompt",
category: "prompt",
label: promptType,
});
},
edit: (promptId: string) => {
trackEvent({
action: "edit_prompt",
category: "prompt",
prompt_id: promptId,
});
},
delete: (promptId: string) => {
trackEvent({
action: "delete_prompt",
category: "prompt",
prompt_id: promptId,
});
},
copy: (promptId?: string) => {
trackEvent({
action: "copy_prompt",
category: "prompt",
prompt_id: promptId,
});
},
upvote: (promptId: string) => {
trackEvent({
action: "upvote_prompt",
category: "prompt",
prompt_id: promptId,
});
},
removeUpvote: (promptId: string) => {
trackEvent({
action: "remove_upvote",
category: "prompt",
prompt_id: promptId,
});
},
report: (promptId: string, reason: string) => {
trackEvent({
action: "report_prompt",
category: "prompt",
label: reason,
prompt_id: promptId,
});
},
run: (promptId: string | undefined, platform: string) => {
trackEvent({
action: "run_prompt",
category: "prompt",
label: platform,
prompt_id: promptId,
});
},
share: (promptId: string | undefined, platform: "twitter" | "hackernews" | "copy_link") => {
trackEvent({
action: "share_prompt",
category: "prompt",
label: platform,
prompt_id: promptId,
});
},
addVersion: (promptId: string) => {
trackEvent({
action: "add_version",
category: "prompt",
prompt_id: promptId,
});
},
compareVersions: (promptId: string) => {
trackEvent({
action: "compare_versions",
category: "prompt",
prompt_id: promptId,
});
},
fillVariables: (promptId?: string) => {
trackEvent({
action: "fill_variables",
category: "prompt",
prompt_id: promptId,
});
},
changeRequest: (promptId: string, action: "create" | "approve" | "dismiss" | "reopen") => {
trackEvent({
action: `change_request_${action}`,
category: "prompt",
prompt_id: promptId,
});
},
pin: (promptId: string) => {
trackEvent({
action: "pin_prompt",
category: "prompt",
prompt_id: promptId,
});
},
unpin: (promptId: string) => {
trackEvent({
action: "unpin_prompt",
category: "prompt",
prompt_id: promptId,
});
},
feature: (promptId: string) => {
trackEvent({
action: "feature_prompt",
category: "prompt",
prompt_id: promptId,
});
},
unfeature: (promptId: string) => {
trackEvent({
action: "unfeature_prompt",
category: "prompt",
prompt_id: promptId,
});
},
};
// ============================================================================
// Search & Filter Events
// ============================================================================
export const analyticsSearch = {
search: (query: string, aiEnabled?: boolean) => {
trackEvent({
action: "search",
category: "search",
label: query,
ai_search: aiEnabled,
});
},
filter: (filterType: string, value: string) => {
trackEvent({
action: "filter",
category: "search",
label: `${filterType}:${value}`,
});
},
clearFilters: () => {
trackEvent({
action: "clear_filters",
category: "search",
});
},
sort: (sortBy: string) => {
trackEvent({
action: "sort",
category: "search",
label: sortBy,
});
},
aiSearchToggle: (enabled: boolean) => {
trackEvent({
action: "ai_search_toggle",
category: "search",
label: enabled ? "enabled" : "disabled",
});
},
};
// ============================================================================
// Navigation Events
// ============================================================================
export const analyticsNav = {
clickNavLink: (destination: string) => {
trackEvent({
action: "nav_click",
category: "navigation",
label: destination,
});
},
viewPage: (pageName: string) => {
trackEvent({
action: "page_view",
category: "navigation",
label: pageName,
});
},
clickLogo: () => {
trackEvent({
action: "logo_click",
category: "navigation",
});
},
openMobileMenu: () => {
trackEvent({
action: "mobile_menu_open",
category: "navigation",
});
},
closeMobileMenu: () => {
trackEvent({
action: "mobile_menu_close",
category: "navigation",
});
},
};
// ============================================================================
// User Profile Events
// ============================================================================
export const analyticsProfile = {
viewProfile: (username: string, isSelf: boolean) => {
trackEvent({
action: "view_profile",
category: "profile",
label: username,
is_self: isSelf,
});
},
follow: (username: string) => {
trackEvent({
action: "follow_user",
category: "profile",
label: username,
});
},
unfollow: (username: string) => {
trackEvent({
action: "unfollow_user",
category: "profile",
label: username,
});
},
updateProfile: () => {
trackEvent({
action: "update_profile",
category: "profile",
});
},
updateAvatar: () => {
trackEvent({
action: "update_avatar",
category: "profile",
});
},
};
// ============================================================================
// Category Events
// ============================================================================
export const analyticsCategory = {
view: (categorySlug: string) => {
trackEvent({
action: "view_category",
category: "category",
label: categorySlug,
});
},
subscribe: (categoryId: string, categoryName: string) => {
trackEvent({
action: "subscribe_category",
category: "category",
label: categoryName,
category_id: categoryId,
});
},
unsubscribe: (categoryId: string, categoryName: string) => {
trackEvent({
action: "unsubscribe_category",
category: "category",
label: categoryName,
category_id: categoryId,
});
},
};
// ============================================================================
// Tag Events
// ============================================================================
export const analyticsTag = {
view: (tagSlug: string) => {
trackEvent({
action: "view_tag",
category: "tag",
label: tagSlug,
});
},
click: (tagSlug: string, source?: string) => {
trackEvent({
action: "click_tag",
category: "tag",
label: tagSlug,
source,
});
},
};
// ============================================================================
// Settings Events
// ============================================================================
export const analyticsSettings = {
changeTheme: (theme: string) => {
trackEvent({
action: "change_theme",
category: "settings",
label: theme,
});
},
changeLanguage: (language: string) => {
trackEvent({
action: "change_language",
category: "settings",
label: language,
});
},
updateNotifications: (enabled: boolean) => {
trackEvent({
action: "update_notifications",
category: "settings",
label: enabled ? "enabled" : "disabled",
});
},
deleteAccount: () => {
trackEvent({
action: "delete_account",
category: "settings",
});
},
};
// ============================================================================
// Hero/Homepage Events
// ============================================================================
export const analyticsHero = {
submitPromptIdea: (promptText: string) => {
trackEvent({
action: "hero_submit_prompt",
category: "homepage",
label: promptText.substring(0, 100),
});
},
clickAnimatedPrompt: () => {
trackEvent({
action: "hero_click_animated",
category: "homepage",
});
},
focusInput: () => {
trackEvent({
action: "hero_focus_input",
category: "homepage",
});
},
};
// ============================================================================
// MCP Events
// ============================================================================
export const analyticsMcp = {
openPopup: () => {
trackEvent({
action: "mcp_popup_open",
category: "mcp",
});
},
copyCommand: (commandType: string) => {
trackEvent({
action: "mcp_copy_command",
category: "mcp",
label: commandType,
});
},
};
// ============================================================================
// Engagement Events
// ============================================================================
export const analyticsEngagement = {
scrollDepth: (percentage: number) => {
trackEvent({
action: "scroll_depth",
category: "engagement",
value: percentage,
});
},
timeOnPage: (seconds: number, pageName: string) => {
trackEvent({
action: "time_on_page",
category: "engagement",
label: pageName,
value: seconds,
});
},
clickExternalLink: (url: string) => {
trackEvent({
action: "external_link_click",
category: "engagement",
label: url,
});
},
};
// ============================================================================
// Admin Events
// ============================================================================
export const analyticsAdmin = {
viewDashboard: () => {
trackEvent({
action: "view_admin_dashboard",
category: "admin",
});
},
manageUsers: (action: "ban" | "unban" | "promote" | "demote") => {
trackEvent({
action: `user_${action}`,
category: "admin",
});
},
managePrompts: (action: "feature" | "unfeature" | "delete" | "approve" | "reject") => {
trackEvent({
action: `prompt_${action}`,
category: "admin",
});
},
importPrompts: (count: number) => {
trackEvent({
action: "import_prompts",
category: "admin",
value: count,
});
},
};