Merge pull request #211 from presenton/feat/basic-telemetry

feat(nextjs): adds basic anonymous tracking that tracks navigation, api calls (Just name of endpoint is tracked) and button clicks
This commit is contained in:
Saurav Niraula 2025-08-12 22:11:24 +05:45 committed by GitHub
commit ae22bf9c35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 497 additions and 45 deletions

View file

@ -48,7 +48,6 @@ Presenton gives you complete control over your AI presentation workflow. Choose
* ✅ **Versatile Image Generation** — Choose from DALL-E 3, Gemini Flash, Pexels, or Pixabay
* ✅ **Rich Media Support** — Icons, charts, and custom graphics for professional presentations
* ✅ **Runs Locally** — All processing happens on your device, no cloud dependencies
* ✅ **Privacy-First** — Zero tracking, no data stored by us, complete data sovereignty
* ✅ **API Deployment** — Host as your own API service for your team
* ✅ **Fully Open-Source** — Apache 2.0 licensed, inspect, modify, and contribute
* ✅ **Docker Ready** — One-command deployment with GPU support for local models
@ -105,6 +104,10 @@ You can also set the following environment variables to customize the image gene
- **GOOGLE_API_KEY=[Your Google API Key]**: Required if using **gemini_flash** as the image provider.
- **OPENAI_API_KEY=[Your OpenAI API Key]**: Required if using **dall-e-3** as the image provider.
You can disable anonymous tracking using the following environment variable:
- **DISABLE_ANONYMOUS_TRACKING=[true/false]**: Set this to **true** to disable anonymous usage tracking.
> **Note:** You can freely choose both the LLM (text generation) and the image provider. Supported image providers: **pexels**, **pixabay**, **gemini_flash** (Google), and **dall-e-3** (OpenAI).
### Using OpenAI

View file

@ -29,6 +29,7 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
production-gpu:
# image: ghcr.io/presenton/presenton:latest
@ -67,6 +68,7 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
development:
build:
@ -97,6 +99,7 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
development-gpu:
build:
@ -134,3 +137,4 @@ services:
- DISABLE_THINKING=${DISABLE_THINKING}
- WEB_GROUNDING=${WEB_GROUNDING}
- DATABASE_URL=${DATABASE_URL}
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}

View file

@ -2,12 +2,15 @@
import { LayoutDashboard, Settings, Upload } from "lucide-react";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { RootState } from "@/store/store";
import { useSelector } from "react-redux";
const HeaderNav = () => {
const canChangeKeys = useSelector((state: RootState) => state.userConfig.can_change_keys);
const pathname = usePathname();
return (
<div className="flex items-center gap-2">
@ -17,6 +20,7 @@ const HeaderNav = () => {
prefetch={false}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}
>
<LayoutDashboard className="w-5 h-5" />
<span className="text-sm font-medium font-inter">
@ -29,6 +33,7 @@ const HeaderNav = () => {
prefetch={false}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/settings" })}
>
<Settings className="w-5 h-5" />
<span className="text-sm font-medium font-inter">

View file

@ -15,6 +15,7 @@ import { PresentationGenerationApi } from "../services/api/presentation-generati
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { PreviousGeneratedImagesResponse } from "../services/api/params";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
interface ImageEditorProps {
initialImage: string | null;
imageIdx?: number;
@ -90,6 +91,7 @@ const ImageEditor = ({
const getPreviousGeneratedImage = async () => {
try {
trackEvent(MixpanelEvent.ImageEditor_GetPreviousGeneratedImages_API_Call);
const response =
await PresentationGenerationApi.getPreviousGeneratedImages();
setPreviousGeneratedImages(response);
@ -187,6 +189,7 @@ const ImageEditor = ({
try {
setIsGenerating(true);
setError(null);
trackEvent(MixpanelEvent.ImageEditor_GenerateImage_API_Call);
const response = await PresentationGenerationApi.generateImage({
prompt: prompt,
});
@ -228,6 +231,7 @@ const ImageEditor = ({
const formData = new FormData();
formData.append("file", file);
trackEvent(MixpanelEvent.ImageEditor_UploadImage_API_Call);
const response = await fetch("/api/upload-image", {
method: "POST",
body: formData,

View file

@ -10,16 +10,18 @@ import { useFileUpload } from "./hooks/useFileUpload";
import { useSlideProcessing } from "./hooks/useSlideProcessing";
import { useLayoutSaving } from "./hooks/useLayoutSaving";
import { useAPIKeyCheck } from "./hooks/useAPIKeyCheck";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { LoadingSpinner } from "./components/LoadingSpinner";
import { FileUploadSection } from "./components/FileUploadSection";
import { SaveLayoutButton } from "./components/SaveLayoutButton";
import { SaveLayoutModal } from "./components/SaveLayoutModal";
import EachSlide from "./components/EachSlide/NewEachSlide";
import { APIKeyWarning } from "./components/APIKeyWarning";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const CustomTemplatePage = () => {
const router = useRouter();
const pathname = usePathname();
const { refetch } = useLayout();
// Custom hooks for different concerns
@ -42,6 +44,7 @@ const CustomTemplatePage = () => {
);
const handleSaveTemplate = async (layoutName: string, description: string): Promise<string | null> => {
trackEvent(MixpanelEvent.CustomTemplate_Save_Templates_API_Call);
const id = await saveLayout(layoutName, description);
if (id) {
router.push(`/template-preview/custom-${id}`);

View file

@ -7,6 +7,7 @@ import BackBtn from "@/components/BackBtn";
import { usePathname } from "next/navigation";
import HeaderNav from "@/app/(presentation-generator)/components/HeaderNab";
import { Layout, FilePlus2 } from "lucide-react";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const Header = () => {
const pathname = usePathname();
return (
@ -15,7 +16,7 @@ const Header = () => {
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-3">
{pathname !== "/upload" && <BackBtn />}
<Link href="/dashboard">
<Link href="/dashboard" onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
<img
src="/logo-white.png"
alt="Presentation logo"
@ -27,6 +28,7 @@ const Header = () => {
<Link
href="/custom-template"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/custom-template" })}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
>
@ -36,6 +38,7 @@ const Header = () => {
<Link
href="/template-preview"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/template-preview" })}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
>

View file

@ -19,7 +19,7 @@ import { OverlayLoader } from "@/components/ui/overlay-loader";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { setPresentationId } from "@/store/slices/presentationGeneration";
import { useDispatch, useSelector } from "react-redux";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import { RootState } from "@/store/store";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@ -28,6 +28,7 @@ import { getIconFromFile } from "../../utils/others";
import { ChevronRight, PanelRightOpen, X } from "lucide-react";
import ToolTip from "@/components/ToolTip";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
// Types
interface LoadingState {
@ -50,6 +51,7 @@ const DocumentsPreviewPage: React.FC = () => {
// Hooks
const dispatch = useDispatch();
const router = useRouter();
const pathname = usePathname();
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Redux state
@ -144,6 +146,7 @@ const DocumentsPreviewPage: React.FC = () => {
const documentPaths = fileItems.map(
(fileItem: FileItem) => fileItem.file_path
);
trackEvent(MixpanelEvent.DocumentsPreview_Create_Presentation_API_Call);
const createResponse = await PresentationGenerationApi.createPresentation(
{
prompt: config?.prompt ?? "",
@ -154,6 +157,7 @@ const DocumentsPreviewPage: React.FC = () => {
);
dispatch(setPresentationId(createResponse.id));
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/outline" });
router.replace("/outline");
} catch (error: any) {
console.error("Error in radar presentation creation:", error);

View file

@ -1,4 +1,6 @@
import React from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { Button } from "@/components/ui/button";
import { LoadingState, LayoutGroup } from "../types/index";
@ -15,6 +17,9 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
selectedLayoutGroup,
onSubmit
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const isDisabled =
loadingState.isLoading ||
streamState.isLoading ||
@ -30,7 +35,11 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
return (
<Button
disabled={isDisabled}
onClick={onSubmit}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Generate_Presentation_Button_Clicked, { pathname, query });
onSubmit();
}}
className="bg-[#5146E5] w-full rounded-lg text-base sm:text-lg py-4 sm:py-6 font-instrument_sans font-semibold hover:bg-[#5146E5]/80 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg

View file

@ -1,5 +1,7 @@
import { CheckCircle } from "lucide-react";
import React from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { LayoutGroup } from "../types/index";
import { useLayout } from "../../context/LayoutContext";
import { useFontLoader } from "../../hooks/useFontLoader";
@ -18,9 +20,15 @@ const GroupLayouts: React.FC<GroupLayoutsProps> = ({
const layoutGroup = getFullDataByGroup(group.id);
const fonts = getCustomTemplateFonts(group.id.split("custom-")[1]);
useFontLoader(fonts || []);
const pathname = usePathname();
const searchParams = useSearchParams();
return (
<div
onClick={() => onSelectLayoutGroup(group)}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Group_Layout_Selected_Clicked, { pathname, query });
onSelectLayoutGroup(group);
}}
className={`relative p-4 rounded-lg border cursor-pointer transition-all duration-200 ${
selectedLayoutGroup?.id === group.id
? "border-blue-500 bg-blue-50 shadow-md"

View file

@ -16,6 +16,8 @@ import {
import { OutlineItem } from "./OutlineItem";
import { Button } from "@/components/ui/button";
import { FileText } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
interface OutlineContentProps {
outlines: { content: string }[] | null;
@ -39,6 +41,9 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
})
);
const pathname = usePathname();
const searchParams = useSearchParams();
return (
<div className="space-y-6 font-instrument_sans">
{/* <div className="flex items-center justify-between">
@ -110,7 +115,11 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
<Button
variant="outline"
onClick={onAddSlide}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Outline_Add_Slide_Button_Clicked, { pathname, query });
onAddSlide();
}}
disabled={isLoading || isStreaming}
className="w-full my-4 text-blue-600 border-blue-200"
>
@ -126,7 +135,11 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
<p className="text-gray-600 mb-4">No outlines available</p>
<Button
variant="outline"
onClick={onAddSlide}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Outline_Add_Slide_Button_Clicked, { pathname, query });
onAddSlide();
}}
className="text-blue-600 border-blue-200"
>
+ Add First Slide

View file

@ -5,6 +5,8 @@ import { RootState } from "@/store/store";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { usePathname, useSearchParams } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { AlertCircle } from "lucide-react";
import { useGroupLayouts } from "../hooks/useGroupLayouts";
import { setPresentationData } from "@/store/slices/presentationGeneration";
@ -16,21 +18,23 @@ import { useFontLoader } from "../hooks/useFontLoader";
const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
const { renderSlideContent, loading } = useGroupLayouts();
const pathname = usePathname();
const searchParams = useSearchParams();
const [contentLoading, setContentLoading] = useState(true);
const {getCustomTemplateFonts} = useLayout()
const { getCustomTemplateFonts } = useLayout()
const dispatch = useDispatch();
const { presentationData } = useSelector(
(state: RootState) => state.presentationGeneration
);
const [error, setError] = useState(false);
useEffect(() => {
if(!loading && presentationData?.slides && presentationData?.slides.length > 0){
if (!loading && presentationData?.slides && presentationData?.slides.length > 0) {
const presentation_id = presentationData?.slides[0].layout.split(":")[0].split("custom-")[1];
const fonts = getCustomTemplateFonts(presentation_id);
useFontLoader(fonts || []);
}
}, [presentationData,loading]);
const fonts = getCustomTemplateFonts(presentation_id);
useFontLoader(fonts || []);
}
}, [presentationData, loading]);
useEffect(() => {
if (presentationData?.slides[0].layout.includes("custom")) {
const existingScript = document.querySelector(
@ -62,7 +66,7 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
setContentLoading(false);
}
};
// Regular view
return (
<div className="flex overflow-hidden flex-col">
@ -82,7 +86,10 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
</p>
<Button
className="mt-4 bg-red-500 text-white hover:bg-red-600 focus:ring-4 focus:ring-red-300"
onClick={() => window.location.reload()}
onClick={() => {
trackEvent(MixpanelEvent.PdfMaker_Retry_Button_Clicked, { pathname, query: searchParams?.toString() });
window.location.reload();
}}
>
Retry
</Button>
@ -95,10 +102,10 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
className="mx-auto flex flex-col items-center overflow-hidden justify-center "
>
{!presentationData ||
loading ||
contentLoading ||
!presentationData?.slides ||
presentationData?.slides.length === 0 ? (
loading ||
contentLoading ||
!presentationData?.slides ||
presentationData?.slides.length === 0 ? (
<div className="relative w-full h-[calc(100vh-120px)] mx-auto ">
<div className=" ">
{Array.from({ length: 2 }).map((_, index) => (
@ -115,7 +122,8 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
presentationData.slides &&
presentationData.slides.length > 0 &&
presentationData.slides.map((slide: any, index: number) => (
<div key={index} className="w-full" data-speaker-note={slide.speaker_note}>
// [data-speaker-note] is used to extract the speaker note from the slide for export to pptx
<div key={index} className="w-full" data-speaker-note={slide.note}>
{renderSlideContent(slide, true)}
</div>
))}

View file

@ -7,7 +7,7 @@ import {
} from "lucide-react";
import React, { useState } from "react";
import Wrapper from "@/components/Wrapper";
import { useRouter } from "next/navigation";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import {
Popover,
PopoverContent,
@ -29,6 +29,7 @@ import HeaderNav from "../../components/HeaderNab";
import PDFIMAGE from "@/public/pdf.svg";
import PPTXIMAGE from "@/public/pptx.svg";
import Image from "next/image";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const Header = ({
presentation_id,
@ -40,6 +41,8 @@ const Header = ({
const [open, setOpen] = useState(false);
const [showLoader, setShowLoader] = useState(false);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { presentationData, isStreaming } = useSelector(
@ -59,13 +62,16 @@ const Header = ({
setOpen(false);
setShowLoader(true);
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call);
const pptx_model = await get_presentation_pptx_model(presentation_id);
if (!pptx_model) {
throw new Error("Failed to get presentation PPTX model");
}
trackEvent(MixpanelEvent.Header_ExportAsPPTX_API_Call);
const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model);
if (pptx_path) {
// window.open(pptx_path, '_self');
@ -92,8 +98,10 @@ const Header = ({
setOpen(false);
setShowLoader(true);
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
const response = await fetch('/api/export-as-pdf', {
method: 'POST',
body: JSON.stringify({
@ -136,14 +144,22 @@ const Header = ({
const ExportOptions = ({ mobile }: { mobile: boolean }) => (
<div className={`space-y-2 max-md:mt-4 ${mobile ? "" : "bg-white"} rounded-lg`}>
<Button
onClick={handleExportPdf}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Header_Export_PDF_Button_Clicked, { pathname, query });
handleExportPdf();
}}
variant="ghost"
className={`pb-4 border-b rounded-none border-gray-300 w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6 border-none rounded-lg" : ""}`} >
<Image src={PDFIMAGE} alt="pdf export" width={30} height={30} />
Export as PDF
</Button>
<Button
onClick={handleExportPptx}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Header_Export_PPTX_Button_Clicked, { pathname, query });
handleExportPptx();
}}
variant="ghost"
className={`w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6" : ""}`}
>
@ -159,7 +175,11 @@ const Header = ({
<div className="flex flex-col lg:flex-row items-center gap-4">
{/* Present Button */}
<Button
onClick={() => router.push(`?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`)}
onClick={() => {
const to = `?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`;
trackEvent(MixpanelEvent.Navigation, { from: pathname, to });
router.push(to);
}}
variant="ghost"
className="border border-white font-bold text-white rounded-[32px] transition-all duration-300 group"
>

View file

@ -8,6 +8,8 @@ import SidePanel from "./SidePanel";
import SlideContent from "./SlideContent";
import Header from "./Header";
import { Button } from "@/components/ui/button";
import { usePathname, useSearchParams } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { AlertCircle, Loader2 } from "lucide-react";
import Help from "./Help";
import {
@ -23,6 +25,8 @@ import { useFontLoader } from "../../hooks/useFontLoader";
const PresentationPage: React.FC<PresentationPageProps> = ({
presentation_id,
}) => {
const pathname = usePathname();
const searchParams = useSearchParams();
// State management
const [loading, setLoading] = useState(true);
const [selectedSlide, setSelectedSlide] = useState(0);
@ -110,7 +114,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
<p className="text-center mb-4">
We couldn't load your presentation. Please try again.
</p>
<Button onClick={() => window.location.reload()}>Refresh Page</Button>
<Button onClick={() => { const query = searchParams?.toString(); trackEvent(MixpanelEvent.PresentationPage_Refresh_Page_Button_Clicked, { pathname, query }); window.location.reload(); }}>Refresh Page</Button>
</div>
</div>
);

View file

@ -17,6 +17,8 @@ import {
updateSlide,
} from "@/store/slices/presentationGeneration";
import { useGroupLayouts } from "../../hooks/useGroupLayouts";
import { usePathname, useSearchParams } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import NewSlide from "../../components/NewSlide";
interface SlideContentProps {
@ -35,6 +37,8 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
// Use the centralized group layouts hook
const { renderSlideContent, loading } = useGroupLayouts();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleSubmit = async () => {
const element = document.getElementById(
@ -48,6 +52,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
setIsUpdating(true);
try {
trackEvent(MixpanelEvent.Slide_Edit_API_Call);
const response = await PresentationGenerationApi.editSlide(
slide.id,
value
@ -149,7 +154,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
<ToolTip content="Add new slide below">
{!isStreaming && !loading && (
<div
onClick={() => setShowNewSlideSelection(true)}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Slide_Add_New_Slide_Button_Clicked, { pathname, query });
setShowNewSlideSelection(true);
}}
className=" bg-white shadow-md w-[80px] py-2 border hover:border-[#5141e5] duration-300 flex items-center justify-center rounded-lg cursor-pointer mx-auto"
>
<PlusIcon className="text-gray-500 text-base cursor-pointer" />
@ -169,7 +178,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
{!isStreaming && !loading && (
<ToolTip content="Delete slide">
<div
onClick={onDeleteSlide}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname, query });
onDeleteSlide();
}}
className="absolute top-2 z-20 sm:top-4 right-2 sm:right-4 hidden md:block transition-transform"
>
<Trash2 className="text-gray-500 text-xl cursor-pointer" />
@ -222,6 +235,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
className={`bg-gradient-to-r from-[#9034EA] to-[#5146E5] rounded-[32px] px-4 py-2 text-white flex items-center justify-end gap-2 ml-auto ${
isUpdating ? "opacity-70 cursor-not-allowed" : ""
}`}
onClick={() => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname, query });
}}
>
{isUpdating ? "Updating..." : "Update"}
<SendHorizontal className="w-4 sm:w-5 h-4 sm:h-5" />

View file

@ -9,10 +9,11 @@ import {
checkIfSelectedOllamaModelIsPulled,
pullOllamaModel,
} from "@/utils/providerUtils";
import { useRouter } from "next/navigation";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import LLMProviderSelection from "@/components/LLMSelection";
import Header from "../dashboard/components/Header";
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
// Button state interface
interface ButtonState {
@ -26,6 +27,8 @@ interface ButtonState {
const SettingsPage = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const userConfigState = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
userConfigState.llm_config
@ -61,6 +64,8 @@ const SettingsPage = () => {
}, [downloadingModel?.downloaded, downloadingModel?.size]);
const handleSaveConfig = async () => {
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname, query });
try {
setButtonState(prev => ({
...prev,
@ -68,13 +73,16 @@ const SettingsPage = () => {
isDisabled: true,
text: "Saving Configuration...",
}));
trackEvent(MixpanelEvent.Settings_SaveConfiguration_API_Call);
await handleSaveLLMConfig(llmConfig);
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
trackEvent(MixpanelEvent.Settings_CheckOllamaModelPulled_API_Call);
const isPulled = await checkIfSelectedOllamaModelIsPulled(
llmConfig.OLLAMA_MODEL
);
if (!isPulled) {
setShowDownloadModal(true);
trackEvent(MixpanelEvent.Settings_DownloadOllamaModel_API_Call);
await handleModelDownload();
}
}
@ -85,6 +93,7 @@ const SettingsPage = () => {
isDisabled: false,
text: "Save Configuration",
}));
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
router.push("/upload");
} catch (error) {
toast.info(error instanceof Error ? error.message : "Failed to save configuration");

View file

@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, usePathname, useSearchParams } from "next/navigation";
import LoadingStates from "../components/LoadingStates";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@ -14,11 +14,14 @@ import "prismjs/components/prism-markup";
import "prismjs/components/prism-jsx";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useFontLoader } from "../../hooks/useFontLoader";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const GroupLayoutPreview = () => {
const params = useParams();
const router = useRouter();
const slug = params.slug as string;
const pathname = usePathname();
const searchParams = useSearchParams();
const { getFullDataByGroup, loading, refetch } = useLayout();
const layoutGroup = getFullDataByGroup(slug);
@ -177,7 +180,11 @@ const GroupLayoutPreview = () => {
<Button
variant="outline"
size="sm"
onClick={() => router.back()}
onClick={() => {
const query = searchParams?.toString?.() as string | undefined;
trackEvent(MixpanelEvent.TemplatePreview_Back_Button_Clicked, { pathname, query });
router.back();
}}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
@ -186,13 +193,20 @@ const GroupLayoutPreview = () => {
<Button
variant="outline"
size="sm"
onClick={() => router.push("/template-preview")}
onClick={() => {
const query = searchParams?.toString?.() as string | undefined;
trackEvent(MixpanelEvent.TemplatePreview_All_Groups_Button_Clicked, { pathname, query });
router.push("/template-preview");
}}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
All Groups
</Button>
{slug.includes('custom-') && <button className=" border border-red-200 flex justify-center items-center gap-2 text-red-700 px-4 py-1 rounded-md" onClick={() => {
const query = searchParams?.toString?.() as string | undefined;
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_Button_Clicked, { pathname, query });
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_API_Call);
deleteLayouts();
}}><Trash2 className="w-4 h-4" />Delete</button>}
</div>
@ -250,7 +264,11 @@ const GroupLayoutPreview = () => {
variant="outline"
size="sm"
className="flex items-center gap-2 bg-blue-50 border border-blue-400 text-blue-700"
onClick={() => openEditor(fileName)}
onClick={() => {
const query = searchParams?.toString?.() as string | undefined;
trackEvent(MixpanelEvent.TemplatePreview_Open_Editor_Button_Clicked, { pathname, query });
openEditor(fileName);
}}
disabled={!layoutsMap[fileName]}
title={!layoutsMap[fileName] ? "Loading layout code..." : "Edit layout code"}
>

View file

@ -1,11 +1,12 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, usePathname } from "next/navigation";
import LoadingStates from "./components/LoadingStates";
import { Card } from "@/components/ui/card";
import { ExternalLink } from "lucide-react";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import { useLayout } from "../context/LayoutContext";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const LayoutPreview = () => {
const {
@ -17,6 +18,7 @@ const LayoutPreview = () => {
error,
} = useLayout();
const router = useRouter();
const pathname = usePathname();
const [summaryMap, setSummaryMap] = useState<Record<string, { lastUpdatedAt?: number; name?: string; description?: string }>>({});
@ -114,7 +116,10 @@ const LayoutPreview = () => {
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
onClick={() => {
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/template-preview/${group.groupName}` });
router.push(`/template-preview/${group.groupName}`)
}}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
@ -166,7 +171,10 @@ const LayoutPreview = () => {
<Card
key={group.groupName}
className="cursor-pointer hover:shadow-md transition-all duration-200 group"
onClick={() => router.push(`/template-preview/${group.groupName}`)}
onClick={() => {
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/template-preview/${group.groupName}` });
router.push(`/template-preview/${group.groupName}`)
}}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">
@ -196,7 +204,10 @@ const LayoutPreview = () => {
) : (
<Card
className="cursor-pointer hover:shadow-md transition-all border-blue-500 duration-200 group"
onClick={() => router.push(`/custom-template`)}
onClick={() => {
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: `/custom-template` });
router.push(`/custom-template`)
}}
>
<div className="p-6">
<div className="flex items-center justify-between mb-3">

View file

@ -11,7 +11,7 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useDispatch } from "react-redux";
import { clearOutlines, setPresentationId } from "@/store/slices/presentationGeneration";
import { ConfigurationSelects } from "./ConfigurationSelects";
@ -25,6 +25,7 @@ import { PresentationGenerationApi } from "../../services/api/presentation-gener
import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
import { setPptGenUploadState } from "@/store/slices/presentationGenUpload";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
// Types for loading state
interface LoadingState {
@ -37,6 +38,8 @@ interface LoadingState {
const UploadPage = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const dispatch = useDispatch();
// State management
@ -115,6 +118,7 @@ const UploadPage = () => {
let documents = [];
if (files.length > 0) {
trackEvent(MixpanelEvent.Upload_Upload_Documents_API_Call);
const uploadResponse = await PresentationGenerationApi.uploadDoc(files);
documents = uploadResponse;
}
@ -122,9 +126,8 @@ const UploadPage = () => {
const promises: Promise<any>[] = [];
if (documents.length > 0) {
promises.push(
PresentationGenerationApi.decomposeDocuments(documents)
);
trackEvent(MixpanelEvent.Upload_Decompose_Documents_API_Call);
promises.push(PresentationGenerationApi.decomposeDocuments(documents));
}
const responses = await Promise.all(promises);
dispatch(setPptGenUploadState({
@ -132,6 +135,7 @@ const UploadPage = () => {
files: responses,
}));
dispatch(clearOutlines())
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/documents-preview" });
router.push("/documents-preview");
};
@ -147,6 +151,7 @@ const UploadPage = () => {
});
// Use the first available layout group for direct generation
trackEvent(MixpanelEvent.Upload_Create_Presentation_API_Call);
const createResponse = await PresentationGenerationApi.createPresentation({
prompt: config?.prompt ?? "",
n_slides: config?.slides ? parseInt(config.slides) : null,
@ -156,6 +161,7 @@ const UploadPage = () => {
dispatch(setPresentationId(createResponse.id));
dispatch(clearOutlines())
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/outline" });
router.push("/outline");
};

View file

@ -0,0 +1,30 @@
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { initMixpanel, trackEvent, MixpanelEvent } from '@/utils/mixpanel';
export function MixpanelInitializer({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
// Initialize once
useEffect(() => {
initMixpanel();
}, []);
// Track page views on route changes
useEffect(() => {
if (!pathname) return;
const query = searchParams?.toString();
const url = query ? `${pathname}?${query}` : pathname;
trackEvent(MixpanelEvent.PageView, { url });
}, [pathname, searchParams]);
return <>{children}</>;
}
export default MixpanelInitializer;

View file

@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET() {
const isDisabled = process.env.DISABLE_ANONYMOUS_TRACKING === 'true' || process.env.DISABLE_ANONYMOUS_TRACKING === 'True';
const trackingEnabled = !isDisabled;
return NextResponse.json({ trackingEnabled });
}

View file

@ -3,6 +3,7 @@ import localFont from "next/font/local";
import { Roboto, Instrument_Sans } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import MixpanelInitializer from "./MixpanelInitializer";
import { LayoutProvider } from "./(presentation-generator)/context/LayoutContext";
import { Toaster } from "@/components/ui/sonner";
const inter = localFont({
@ -85,9 +86,11 @@ export default function RootLayout({
className={`${inter.variable} ${roboto.variable} ${instrument_sans.variable} antialiased`}
>
<Providers>
<LayoutProvider>
{children}
</LayoutProvider>
<MixpanelInitializer>
<LayoutProvider>
{children}
</LayoutProvider>
</MixpanelInitializer>
</Providers>
<Toaster position="top-center" />
</body>

View file

@ -12,6 +12,8 @@ import {
pullOllamaModel,
} from "@/utils/providerUtils";
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePathname, useSearchParams } from "next/navigation";
// Button state interface
interface ButtonState {
@ -25,6 +27,8 @@ interface ButtonState {
export default function Home() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const config = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(config.llm_config);
@ -52,6 +56,9 @@ export default function Home() {
}, [downloadingModel?.downloaded, downloadingModel?.size]);
const handleSaveConfig = async () => {
// Track button click with pathname and searchParams
const query = searchParams?.toString();
trackEvent(MixpanelEvent.Home_SaveConfiguration_Button_Clicked, { pathname, query });
try {
setButtonState(prev => ({
...prev,
@ -59,11 +66,17 @@ export default function Home() {
isDisabled: true,
text: "Saving Configuration..."
}));
// API: save config
trackEvent(MixpanelEvent.Home_SaveConfiguration_API_Call);
await handleSaveLLMConfig(llmConfig);
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
// API: check model pulled
trackEvent(MixpanelEvent.Home_CheckOllamaModelPulled_API_Call);
const isPulled = await checkIfSelectedOllamaModelIsPulled(llmConfig.OLLAMA_MODEL);
if (!isPulled) {
setShowDownloadModal(true);
// API: download model
trackEvent(MixpanelEvent.Home_DownloadOllamaModel_API_Call);
await handleModelDownload();
}
}
@ -74,6 +87,8 @@ export default function Home() {
isDisabled: false,
text: "Save Configuration"
}));
// Track navigation from -> to
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
router.push("/upload");
} catch (error) {
toast.info(error instanceof Error ? error.message : "Failed to save configuration");

View file

@ -45,6 +45,7 @@
"lucide-react": "^0.447.0",
"marked": "^15.0.11",
"mermaid": "^11.9.0",
"mixpanel-browser": "^2.67.0",
"next": "^14.2.14",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
@ -2827,6 +2828,16 @@
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.18.tgz",
"integrity": "sha512-iMH3amHthJZ9x3gGmBPmdfim7wLGygC2GciIkw2A6SO8giSn8PHYtRT8OKNH4V+k3SZ6RSnYHcTQxBA7pSWZ3Q=="
},
"node_modules/@rrweb/utils": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.0-alpha.18.tgz",
"integrity": "sha512-qV8azQYo9RuwW4NGRtOiQfTBdHNL1B0Q//uRLMbCSjbaKqJYd88Js17Bdskj65a0Vgp2dwTLPIZ0gK47dfjfaA=="
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
@ -3334,6 +3345,11 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q=="
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@ -3724,6 +3740,11 @@
"@types/node": "*"
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw=="
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -7110,6 +7131,14 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mixpanel-browser": {
"version": "2.67.0",
"resolved": "https://registry.npmjs.org/mixpanel-browser/-/mixpanel-browser-2.67.0.tgz",
"integrity": "sha512-LudY4eRIkvjEpAlIAg10i2T2mbtiKZ4XlMGbTyF1kcAhEqMa9JhEEdEcjxYPwiKhuMVSBM3RVkNCZaNqcnE4ww==",
"dependencies": {
"rrweb": "2.0.0-alpha.18"
}
},
"node_modules/mlly": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
@ -8417,6 +8446,64 @@
"points-on-path": "^0.2.1"
}
},
"node_modules/rrdom": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.18.tgz",
"integrity": "sha512-fSFzFFxbqAViITyYVA4Z0o5G6p1nEqEr/N8vdgSKie9Rn0FJxDSNJgjV0yiCIzcDs0QR+hpvgFhpbdZ6JIr5Nw==",
"dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.18"
}
},
"node_modules/rrweb": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.18.tgz",
"integrity": "sha512-1mjZcB+LVoGSx1+i9E2ZdAP90fS3MghYVix2wvGlZvrgRuLCbTCCOZMztFCkKpgp7/EeCdYM4nIHJkKX5J1Nmg==",
"dependencies": {
"@rrweb/types": "^2.0.0-alpha.18",
"@rrweb/utils": "^2.0.0-alpha.18",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0",
"rrdom": "^2.0.0-alpha.18",
"rrweb-snapshot": "^2.0.0-alpha.18"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz",
"integrity": "sha512-hBHZL/NfgQX6wO1D9mpwqFu1NJPpim+moIcKhFEjVTZVRUfCln+LOugRc4teVTCISYHN8Cw5e2iNTWCSm+SkoA==",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rrweb-snapshot/node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",

View file

@ -47,6 +47,7 @@
"lucide-react": "^0.447.0",
"marked": "^15.0.11",
"mermaid": "^11.9.0",
"mixpanel-browser": "^2.67.0",
"next": "^14.2.14",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",

View file

@ -0,0 +1,156 @@
'use client';
import mixpanel from 'mixpanel-browser';
const MIXPANEL_TOKEN = 'd726e8bea8ec147f4c7720060cb2e6d1';
export enum MixpanelEvent {
PageView = 'Page View',
Navigation = 'Navigation',
Home_SaveConfiguration_Button_Clicked = 'Home Save Configuration Button Clicked',
Home_SaveConfiguration_API_Call = 'Home Save Configuration API Call',
Home_CheckOllamaModelPulled_API_Call = 'Home Check Ollama Model Pulled API Call',
Home_DownloadOllamaModel_API_Call = 'Home Download Ollama Model API Call',
Generate_Presentation_Button_Clicked = 'Generate Presentation Button Clicked',
Outline_Add_Slide_Button_Clicked = 'Outline Add Slide Button Clicked',
Group_Layout_Selected_Clicked = 'Group Layout Selected Clicked',
Header_Export_PDF_Button_Clicked = 'Header Export PDF Button Clicked',
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call',
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',
Slide_Update_From_Prompt_Button_Clicked = 'Slide Update From Prompt Button Clicked',
Slide_Edit_API_Call = 'Slide Edit API Call',
TemplatePreview_Back_Button_Clicked = 'Template Preview Back Button Clicked',
TemplatePreview_All_Groups_Button_Clicked = 'Template Preview All Groups Button Clicked',
TemplatePreview_Delete_Templates_Button_Clicked = 'Template Preview Delete Templates Button Clicked',
TemplatePreview_Delete_Templates_API_Call = 'Template Preview Delete Templates API Call',
TemplatePreview_Open_Editor_Button_Clicked = 'Template Preview Open Editor Button Clicked',
CustomTemplate_Save_Templates_API_Call = 'Custom Template Save Templates API Call',
PdfMaker_Retry_Button_Clicked = 'PDF Maker Retry Button Clicked',
Upload_Upload_Documents_API_Call = 'Upload Upload Documents API Call',
Upload_Decompose_Documents_API_Call = 'Upload Decompose Documents API Call',
Upload_Create_Presentation_API_Call = 'Upload Create Presentation API Call',
DocumentsPreview_Create_Presentation_API_Call = 'Documents Preview Create Presentation API Call',
DocumentsPreview_Next_Button_Clicked = 'Documents Preview Next Button Clicked',
Settings_SaveConfiguration_Button_Clicked = 'Settings Save Configuration Button Clicked',
Settings_SaveConfiguration_API_Call = 'Settings Save Configuration API Call',
Settings_CheckOllamaModelPulled_API_Call = 'Settings Check Ollama Model Pulled API Call',
Settings_DownloadOllamaModel_API_Call = 'Settings Download Ollama Model API Call',
PresentationPage_Refresh_Page_Button_Clicked = 'Presentation Page Refresh Page Button Clicked',
PresentationMode_Fullscreen_Toggle_Clicked = 'Presentation Mode Fullscreen Toggle Clicked',
PresentationMode_Exit_Clicked = 'Presentation Mode Exit Clicked',
ImageEditor_GetPreviousGeneratedImages_API_Call = 'Image Editor Get Previous Generated Images API Call',
ImageEditor_GenerateImage_API_Call = 'Image Editor Generate Image API Call',
ImageEditor_UploadImage_API_Call = 'Image Editor Upload Image API Call',
}
export type MixpanelProps = Record<string, unknown>;
declare global {
interface Window {
__mixpanel_initialized?: boolean;
__mixpanel_tracking_enabled?: boolean;
}
}
function canUseMixpanel(): boolean {
return typeof window !== 'undefined' && Boolean(MIXPANEL_TOKEN);
}
let trackingCheckPromise: Promise<boolean> | null = null;
async function ensureTrackingStatus(): Promise<boolean> {
if (typeof window === 'undefined') return false;
if (typeof window.__mixpanel_tracking_enabled === 'boolean') {
return window.__mixpanel_tracking_enabled;
}
if (!trackingCheckPromise) {
trackingCheckPromise = fetch('/api/tracking-status')
.then(async (res) => {
try {
const data = await res.json();
const enabled = Boolean(data?.trackingEnabled);
window.__mixpanel_tracking_enabled = enabled;
return enabled;
} catch {
// If the API response is malformed, default to enabling tracking
window.__mixpanel_tracking_enabled = true;
return true;
}
})
.catch(() => {
// If the API call fails, default to enabling tracking
window.__mixpanel_tracking_enabled = true;
return true;
});
}
return trackingCheckPromise;
}
export function initMixpanel(): void {
if (!canUseMixpanel()) return;
if (window.__mixpanel_initialized) return;
// Ensure tracking is allowed before initializing
void ensureTrackingStatus().then((enabled) => {
if (!enabled) return;
if (window.__mixpanel_initialized) return;
mixpanel.init(MIXPANEL_TOKEN as string, { track_pageview: false });
mixpanel.identify(mixpanel.get_distinct_id());
window.__mixpanel_initialized = true;
});
}
export function track(eventName: string, props?: Record<string, unknown>): void {
if (!canUseMixpanel()) return;
if (typeof window !== 'undefined' && window.__mixpanel_tracking_enabled === false) {
return;
}
if (!window.__mixpanel_initialized) {
initMixpanel();
return;
}
mixpanel.track(eventName, props);
}
export function trackEvent(event: MixpanelEvent, props?: MixpanelProps): void {
track(event, props);
}
export function getDistinctId(): string | undefined {
if (!canUseMixpanel()) return undefined;
if (typeof window !== 'undefined' && window.__mixpanel_tracking_enabled === false) {
return undefined;
}
if (!window.__mixpanel_initialized) {
initMixpanel();
return undefined;
}
if (!window.__mixpanel_initialized) return undefined;
return mixpanel.get_distinct_id();
}
export function identifyAnonymous(): void {
if (!canUseMixpanel()) return;
if (typeof window !== 'undefined' && window.__mixpanel_tracking_enabled === false) {
return;
}
if (!window.__mixpanel_initialized) {
initMixpanel();
return;
}
mixpanel.identify(mixpanel.get_distinct_id());
}
export default {
initMixpanel,
track,
trackEvent,
getDistinctId,
identifyAnonymous,
};