feat(auth): update analytics tracking for authentication and registration components
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user