diff --git a/servers/nextjs/.codex b/servers/nextjs/.codex new file mode 100644 index 00000000..e69de29b diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/DashboardPage.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/DashboardPage.tsx index 25b0024d..2a1a1b00 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/DashboardPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/DashboardPage.tsx @@ -7,16 +7,17 @@ import { PresentationGrid } from "@/app/(presentation-generator)/(dashboard)/das import Link from "next/link"; import { ChevronRight } from "lucide-react"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; +import { usePathname } from "next/navigation"; const DashboardPage: React.FC = () => { + const pathname = usePathname(); const [presentations, setPresentations] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - trackEvent(MixpanelEvent.Dashboard_Page_Viewed); const loadData = async () => { await fetchPresentations(); }; @@ -24,19 +25,28 @@ const DashboardPage: React.FC = () => { }, []); const fetchPresentations = async () => { + let fetchedCount = 0; + let hasError = false; try { setIsLoading(true); setError(null); const data = await DashboardApi.getPresentations(); + fetchedCount = data.length; data.sort( (a: any, b: any) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() ); setPresentations(data); } catch (err) { + hasError = true; setError(null); setPresentations([]); } finally { + trackEvent(MixpanelEvent.Dashboard_Page_Viewed, { + pathname, + presentation_count: fetchedCount, + load_failed: hasError, + }); setIsLoading(false); } }; @@ -61,7 +71,7 @@ const DashboardPage: React.FC = () => { trackEvent(MixpanelEvent.Dashboard_New_Presentation_Clicked)} + onClick={() => trackEvent(MixpanelEvent.Dashboard_New_Presentation_Clicked, { pathname, source: "dashboard_header" })} className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-semibold font-syne shadow-sm hover:shadow-md" aria-label="Create new presentation" style={{ diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationCard.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationCard.tsx index 3155efc2..40970fcc 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationCard.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationCard.tsx @@ -9,7 +9,7 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { toast } from "sonner"; import { useFontLoader } from "@/app/(presentation-generator)/hooks/useFontLoad"; @@ -29,9 +29,16 @@ export const PresentationCard = ({ onDeleted?: (presentationId: string) => void; }) => { const router = useRouter(); + const pathname = usePathname(); const handlePreview = (e: React.MouseEvent) => { e.preventDefault(); + trackEvent(MixpanelEvent.Dashboard_Presentation_Opened, { + pathname, + presentation_id: id, + title_length: (title || "").length, + slide_count: presentation?.slides?.length || 0, + }); router.push(`/presentation?id=${id}&type=standard`); }; useEffect(() => { @@ -84,6 +91,11 @@ export const PresentationCard = ({ const response = await DashboardApi.deletePresentation(id); if (response) { + trackEvent(MixpanelEvent.Dashboard_Presentation_Deleted, { + pathname, + presentation_id: id, + slide_count: presentation?.slides?.length || 0, + }); toast.success("Presentation deleted", { description: "The presentation has been deleted successfully", }); diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid.tsx index dd73c520..e84d0c39 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid.tsx @@ -1,7 +1,7 @@ import React from "react"; import { PresentationCard } from "./PresentationCard"; import { PlusIcon } from "@radix-ui/react-icons"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { PresentationResponse } from "@/app/(presentation-generator)/services/api/dashboard"; import { ArrowRight } from "lucide-react"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; @@ -22,8 +22,13 @@ export const PresentationGrid = ({ onPresentationDeleted, }: PresentationGridProps) => { const router = useRouter(); + const pathname = usePathname(); const handleCreateNewPresentation = () => { - trackEvent(MixpanelEvent.Dashboard_Create_New_Card_Clicked, { type }); + trackEvent(MixpanelEvent.Dashboard_Create_New_Card_Clicked, { + pathname, + type, + source: "dashboard_grid_card", + }); if (type === "slide") { router.push("/upload"); } else { diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/index.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/index.tsx index bd736a51..1e342628 100644 --- a/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/index.tsx +++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/theme/components/ThemePanel/index.tsx @@ -27,11 +27,12 @@ import { Theme, ThemeParams } from '@/app/(presentation-generator)/services/api/ import { ImagesApi } from '@/app/(presentation-generator)/services/api/images' import { Input } from '@/components/ui/input' import { getTemplatesByTemplateName } from '@/app/presentation-templates' -import { useSearchParams } from 'next/navigation' +import { usePathname, useSearchParams } from 'next/navigation' import CustomTabEmpty from './CustomTabEmpty' import ThemeApi from '@/app/(presentation-generator)/services/api/theme' import { useFontLoader } from '@/app/(presentation-generator)/hooks/useFontLoad' import Link from 'next/link' +import { MixpanelEvent, trackEvent } from '@/utils/mixpanel' // Fallback theme used before defaults are loaded from API (unified Theme type) const FALLBACK_THEME: Theme = { @@ -67,6 +68,7 @@ const FALLBACK_THEME: Theme = { } const ThemePanel: React.FC = () => { const searchParams = useSearchParams() + const pathname = usePathname() const [selectedTheme, setSelectedTheme] = useState(FALLBACK_THEME) @@ -91,6 +93,10 @@ const ThemePanel: React.FC = () => { const slideContainerRef = useRef(null) const [slideContainerWidth, setSlideContainerWidth] = useState(0) + useEffect(() => { + trackEvent(MixpanelEvent.Theme_Page_Viewed, { pathname }) + }, [pathname]) + // Calculate scale dynamically based on container width const slideScale = () => { const BASE_WIDTH = 1280 @@ -258,6 +264,18 @@ const ThemePanel: React.FC = () => { setThemeCompanyName(theme.company_name || '') applyTheme(theme) + trackEvent(MixpanelEvent.Theme_Selected, { + pathname, + theme_id: theme.id, + theme_name: theme.name, + theme_source: theme.user === 'system' ? 'built_in' : 'custom', + }) + trackEvent(MixpanelEvent.Theme_Editor_Opened, { + pathname, + theme_id: theme.id, + theme_name: theme.name, + theme_source: theme.user === 'system' ? 'built_in' : 'custom', + }) } const handleColorChange = (colorKey: keyof ThemeColors, value: string) => { @@ -272,6 +290,12 @@ const ThemePanel: React.FC = () => { const handleFontSelect = (fontName: string, url: string) => { setCustomFonts({ textFont: { name: fontName, url: url } }) + trackEvent(MixpanelEvent.Theme_Font_Changed, { + pathname, + font_name: fontName, + font_url: url, + theme_id: selectedTheme.id, + }) } const handleBrandLogoUpload = async (file: File) => { @@ -280,6 +304,12 @@ const ThemePanel: React.FC = () => { const uploaded = await ImagesApi.uploadImage(file) setCustomBrandLogo(uploaded.path) setCustomBrandLogoId(uploaded.id) + trackEvent(MixpanelEvent.Theme_Logo_Uploaded, { + pathname, + theme_id: selectedTheme.id, + file_name: file.name, + file_size_bytes: file.size, + }) } catch (error: any) { console.error('Failed to upload logo', error) toast.error(error?.message || 'Failed to upload logo') @@ -288,8 +318,19 @@ const ThemePanel: React.FC = () => { } } - const generateTheme = async ({ primary, background }: { primary?: string, background?: string }): Promise => { + const generateTheme = async ({ + primary, + background, + source, + }: { primary?: string, background?: string; source: "new_theme" | "refresh" }): Promise => { const generatedTheme = await ThemeApi.generateTheme({ primary, background }) + trackEvent(MixpanelEvent.Theme_Palette_Generated, { + pathname, + source, + theme_id: selectedTheme.id, + has_primary_seed: Boolean(primary), + has_background_seed: Boolean(background), + }) return { 'primary': generatedTheme.primary, 'background': generatedTheme.background, @@ -311,6 +352,7 @@ const ThemePanel: React.FC = () => { } const createNewCustomTheme = async () => { + trackEvent(MixpanelEvent.Theme_New_Theme_Clicked, { pathname }) setIsNewTheme(true) const newTheme: Theme = { id: `custom-${Date.now()}`, @@ -345,7 +387,7 @@ const ThemePanel: React.FC = () => { } } - const generatedColors = await generateTheme({}) + const generatedColors = await generateTheme({ source: "new_theme" }) const theme = { @@ -365,10 +407,16 @@ const ThemePanel: React.FC = () => { setThemeCompanyName('') applyTheme(theme) + trackEvent(MixpanelEvent.Theme_Editor_Opened, { + pathname, + theme_id: theme.id, + theme_name: theme.name, + theme_source: "new_draft", + }) } const refeshTheme = async ({ primary, background }: { primary?: string, background?: string }) => { - const generatedTheme = await generateTheme({ primary, background }) + const generatedTheme = await generateTheme({ primary, background, source: "refresh" }) setCustomColors(generatedTheme) } const saveAsCustom = async () => { @@ -376,6 +424,12 @@ const ThemePanel: React.FC = () => { if (selectedTheme.user && selectedTheme.user !== 'system' && !selectedTheme.id.startsWith('custom-')) { ; (async () => { try { + trackEvent(MixpanelEvent.Theme_Save_Started, { + pathname, + mode: "update", + theme_id: selectedTheme.id, + theme_name: selectedTheme.name, + }) const params: ThemeParams = { id: selectedTheme.id, name: selectedTheme.name, @@ -392,6 +446,14 @@ const ThemePanel: React.FC = () => { setCustomThemes(customThemes.map(t => t.id === updated.id ? updated : t)) setSelectedTheme(updated) setIsSheetOpen(false) + trackEvent(MixpanelEvent.Theme_Saved, { + pathname, + mode: "update", + theme_id: updated.id, + theme_name: updated.name, + has_logo: Boolean(updated.logo_url), + font_name: updated.data?.fonts?.textFont?.name || "", + }) toast.success('Theme updated') } catch (error: any) { console.error('Failed to update theme', error) @@ -401,6 +463,12 @@ const ThemePanel: React.FC = () => { return } try { + trackEvent(MixpanelEvent.Theme_Save_Started, { + pathname, + mode: "create", + theme_id: selectedTheme.id, + theme_name: selectedTheme.name, + }) const params: ThemeParams = { name: selectedTheme.name, description: selectedTheme.description || `Custom version of ${selectedTheme.name}`, @@ -419,6 +487,14 @@ const ThemePanel: React.FC = () => { window.history.pushState({}, '', '/theme') + trackEvent(MixpanelEvent.Theme_Saved, { + pathname, + mode: "create", + theme_id: created.id, + theme_name: created.name, + has_logo: Boolean(created.logo_url), + font_name: created.data?.fonts?.textFont?.name || "", + }) toast.success('Theme saved') } catch (error: any) { console.error('Failed to save theme', error) @@ -432,6 +508,10 @@ const ThemePanel: React.FC = () => { const handleDelete = async (themeId: string) => { await ThemeApi.deleteTheme(themeId) setCustomThemes(customThemes.filter(theme => theme.id !== themeId)) + trackEvent(MixpanelEvent.Theme_Deleted, { + pathname, + theme_id: themeId, + }) toast.success("Theme deleted successfully") } const handleCustomFontChange = async (fontFile: File) => { @@ -444,6 +524,19 @@ const ThemePanel: React.FC = () => { url: url, } }) + trackEvent(MixpanelEvent.Theme_Custom_Font_Uploaded, { + pathname, + font_name: name, + file_name: fontFile.name, + file_size_bytes: fontFile.size, + }) + trackEvent(MixpanelEvent.Theme_Font_Changed, { + pathname, + theme_id: selectedTheme.id, + font_name: name, + font_url: url, + source: "uploaded_font", + }) // Add the newly uploaded font to userFonts if not already present if (!userFonts.fonts.find(f => f.name === name)) { setUserFonts(prev => ({ @@ -853,6 +946,10 @@ const ThemePanel: React.FC = () => { trackEvent(MixpanelEvent.Theme_New_Theme_Clicked, { + pathname, + source: "theme_page_header", + })} className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-semibold font-syne shadow-sm hover:shadow-md" aria-label="Create new theme" style={{ @@ -869,7 +966,10 @@ const ThemePanel: React.FC = () => { {/* Tabs */}
diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/SidePanel.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/SidePanel.tsx index 1e2fb263..6dea5336 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/SidePanel.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/SidePanel.tsx @@ -21,8 +21,9 @@ import { setPresentationData } from "@/store/slices/presentationGeneration"; import { SortableSlide } from "./SortableSlide"; import SlideScale from "../../components/PresentationRender"; import { Separator } from "@/components/ui/separator"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import NewSlide from "./NewSlide"; +import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; interface SidePanelProps { selectedSlide: number; @@ -41,6 +42,7 @@ const SidePanel = ({ }: SidePanelProps) => { const router = useRouter(); + const pathname = usePathname(); const [showNewSlideSelection, setShowNewSlideSelection] = useState(false); const { presentationData, isStreaming } = useSelector( @@ -109,6 +111,13 @@ const SidePanel = ({ dispatch( setPresentationData({ ...presentationData, slides: updatedArray }) ); + trackEvent(MixpanelEvent.Presentation_Slides_Reordered, { + pathname, + presentation_id: presentationId, + from_index: oldIndex, + to_index: newIndex, + slide_count: updatedArray.length, + }); } }; diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx index d1d1261a..a7de6326 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx @@ -19,7 +19,6 @@ import { import { usePathname } from "next/navigation"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { addToHistory } from "@/store/slices/undoRedoSlice"; -import { V1ContentRender } from "../../components/V1ContentRender"; import NewSlide from "./NewSlide"; import SlideScale from "../../components/PresentationRender"; @@ -52,8 +51,6 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { setIsUpdating(true); try { - trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname }); - trackEvent(MixpanelEvent.Slide_Edit_API_Call); const response = await PresentationGenerationApi.editSlide( slide.id, editPrompt @@ -61,6 +58,15 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { if (response) { dispatch(updateSlide({ index: slide.index, slide: response })); + trackEvent(MixpanelEvent.Presentation_Slide_Updated, { + pathname, + presentation_id: presentationId, + slide_id: slide.id, + slide_index: slide.index, + layout: slide.layout, + prompt_char_count: editPrompt.trim().length, + prompt_word_count: editPrompt.trim().split(/\s+/).filter(Boolean).length, + }); toast.success("Slide updated successfully"); setEditPrompt(""); } @@ -76,8 +82,13 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { const onDeleteSlide = async () => { try { - trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname }); - trackEvent(MixpanelEvent.Slide_Delete_API_Call); + trackEvent(MixpanelEvent.Presentation_Slide_Deleted, { + pathname, + presentation_id: presentationId, + slide_id: slide.id, + slide_index: slide.index, + layout: slide.layout, + }); // Add current state to past dispatch(addToHistory({ slides: presentationData?.slides, @@ -154,7 +165,6 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => { {!isStreaming && (
{ - trackEvent(MixpanelEvent.Slide_Add_New_Slide_Button_Clicked, { pathname }); 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" diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx index 46e20aec..610b7250 100644 --- a/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx +++ b/servers/nextjs/app/(presentation-generator)/presentation/components/ThemeSelector.tsx @@ -5,17 +5,19 @@ import { Palette } from 'lucide-react'; import { useDispatch } from 'react-redux'; import { updateTheme } from '@/store/slices/presentationGeneration'; -import { useRouter } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { applyPresentationThemeToElement, clearPresentationThemeFromElement, } from "../utils/applyPresentationThemeDom"; +import { trackEvent, MixpanelEvent } from '@/utils/mixpanel'; const ThemeSelector = ({ current_theme, themes: allThemes }: { current_theme: any, themes: any[] }) => { const [currentTheme, setCurrentTheme] = useState(current_theme) const dispatch = useDispatch() const [isOpen, setIsOpen] = useState(false) const router = useRouter() + const pathname = usePathname() const applyTheme = async (theme: any) => { const element = document.getElementById("presentation-slides-wrapper"); if (!element) return; @@ -25,12 +27,19 @@ const ThemeSelector = ({ current_theme, themes: allThemes }: { current_theme: an if (!theme.data?.colors?.["graph_0"]) return; applyPresentationThemeToElement(element, theme); dispatch(updateTheme(theme)); + trackEvent(MixpanelEvent.Presentation_Theme_Changed, { + pathname, + theme_id: theme.id, + theme_name: theme.name, + theme_source: theme.user === "system" ? "built_in" : "custom", + }); }; const resetTheme = async () => { dispatch(updateTheme(null)); clearPresentationThemeFromElement( document.getElementById("presentation-slides-wrapper") ); + trackEvent(MixpanelEvent.Presentation_Theme_Reset, { pathname }); }; @@ -43,7 +52,10 @@ const ThemeSelector = ({ current_theme, themes: allThemes }: { current_theme: an
- +
@@ -76,4 +88,4 @@ const ThemeSelector = ({ current_theme, themes: allThemes }: { current_theme: an ) } -export default ThemeSelector \ No newline at end of file +export default ThemeSelector diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx index 76726bea..5cef3714 100644 --- a/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx +++ b/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx @@ -154,6 +154,13 @@ const UploadPage = () => { }; }; + const trackUploadValidationFailure = (reason: string) => { + trackEvent(MixpanelEvent.Upload_Configuration_Invalid, { + ...getUploadSnapshotProps(), + reason, + }); + }; + const handleConfigChange = (key: keyof PresentationConfig, value: unknown) => { setConfig((prev) => ({ ...prev, [key]: value } as PresentationConfig)); }; @@ -194,28 +201,19 @@ const UploadPage = () => { */ const validateConfiguration = (): boolean => { if (!config.language) { - trackEvent(MixpanelEvent.Upload_Validation_Failed, { - ...getUploadSnapshotProps(), - reason: "language_missing", - }); + trackUploadValidationFailure("language_missing"); toast.error("Please select language"); return false; } if (files.length > 0 && config.language === LanguageType.Auto) { - trackEvent(MixpanelEvent.Upload_Validation_Failed, { - ...getUploadSnapshotProps(), - reason: "language_auto_with_documents", - }); + trackUploadValidationFailure("language_auto_with_documents"); toast.error("Please choose a language before processing uploaded documents"); return false; } if (!config.prompt.trim() && files.length === 0) { - trackEvent(MixpanelEvent.Upload_Validation_Failed, { - ...getUploadSnapshotProps(), - reason: "prompt_or_document_missing", - }); + trackUploadValidationFailure("prompt_or_document_missing"); toast.error("No Prompt or Document Provided"); return false; } @@ -227,15 +225,12 @@ const UploadPage = () => { */ const handleGeneratePresentation = async () => { if (!validateConfiguration()) return; + trackEvent(MixpanelEvent.Upload_Generation_Started, getUploadSnapshotProps()); - trackEvent(MixpanelEvent.Upload_GetStarted_Button_Clicked, getUploadSnapshotProps()); const isStockProviderReady = await ensureStockImageProviderReady(); if (!isStockProviderReady) { - trackEvent(MixpanelEvent.Upload_Validation_Failed, { - ...getUploadSnapshotProps(), - reason: "stock_image_provider_unreachable", - }); + trackUploadValidationFailure("stock_image_provider_unreachable"); return; } @@ -267,7 +262,6 @@ const UploadPage = () => { let documents = []; if (files.length > 0) { - trackEvent(MixpanelEvent.Upload_Upload_Documents_API_Call); const uploadResponse = await PresentationGenerationApi.uploadDoc(files); documents = uploadResponse; } @@ -277,7 +271,6 @@ const UploadPage = () => { const promises: Promise[] = []; if (documents.length > 0) { - trackEvent(MixpanelEvent.Upload_Decompose_Documents_API_Call); promises.push( PresentationGenerationApi.decomposeDocuments( documents, @@ -291,6 +284,12 @@ const UploadPage = () => { files: responses, })); dispatch(clearOutlines()) + trackEvent(MixpanelEvent.Upload_Documents_Processed, { + ...getUploadSnapshotProps(), + uploaded_documents_count: documents.length, + decompose_job_count: responses.length, + destination: "/documents-preview", + }); trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/documents-preview" }); router.push("/documents-preview"); }; @@ -309,7 +308,6 @@ const UploadPage = () => { const selectedLanguage = config?.language ?? ""; // Use the first available layout group for direct generation - trackEvent(MixpanelEvent.Upload_Create_Presentation_API_Call); const createResponse = await PresentationGenerationApi.createPresentation({ content: config?.prompt ?? "", n_slides: config?.slides ? parseInt(config.slides, 10) : null, @@ -326,6 +324,11 @@ const UploadPage = () => { dispatch(setPresentationId(createResponse.id)); dispatch(clearOutlines()) + trackEvent(MixpanelEvent.Upload_Outline_Generation_Requested, { + ...getUploadSnapshotProps(), + presentation_id: createResponse.id, + destination: "/outline", + }); trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/outline" }); router.push("/outline"); }; @@ -375,7 +378,7 @@ const UploadPage = () => {
-

Attachments (optional)

+

Attachments (optional)

{ ); const handleGoToDashboard = () => { + trackEvent(MixpanelEvent.Onboarding_Completed, { + pathname, + destination: "/dashboard", + }); trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" }); router.push("/dashboard"); }; const handleGoToUpload = () => { + trackEvent(MixpanelEvent.Onboarding_Completed, { + pathname, + destination: "/upload", + }); trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" }); router.push("/upload"); }; diff --git a/servers/nextjs/components/OnBoarding/PresentonMode.tsx b/servers/nextjs/components/OnBoarding/PresentonMode.tsx index 395f8b33..b947e1ad 100644 --- a/servers/nextjs/components/OnBoarding/PresentonMode.tsx +++ b/servers/nextjs/components/OnBoarding/PresentonMode.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Button } from '../ui/button'; -import { Check, CheckCircle, ChevronLeft, ChevronRight, ChevronUp, Download, Eye, EyeOff, Loader2 } from 'lucide-react'; +import { Check, CheckCircle, ChevronLeft, ChevronUp, Download, Eye, EyeOff, Loader2 } from 'lucide-react'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command'; import { DALLE_3_QUALITY_OPTIONS, GPT_IMAGE_1_5_QUALITY_OPTIONS, IMAGE_PROVIDERS, LLM_PROVIDERS } from '@/utils/providerConstants'; import { cn } from '@/lib/utils'; @@ -13,7 +13,7 @@ import ToolTip from '../ToolTip'; import { Switch } from '../ui/switch'; import { Select, SelectItem, SelectContent, SelectValue, SelectTrigger } from '../ui/select'; import { MixpanelEvent, trackEvent } from '@/utils/mixpanel'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { handleSaveLLMConfig } from '@/utils/storeHelpers'; import { checkIfSelectedOllamaModelIsPulled, pullOllamaModel } from '@/utils/providerUtils'; import { getApiUrl } from '@/utils/api'; @@ -21,7 +21,6 @@ import CodexConfig, { CHATGPT_MODELS } from '../CodexConfig'; const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: (step: number) => void }) => { const pathname = usePathname(); - const router = useRouter(); const [openProviderSelect, setOpenProviderSelect] = useState(false); const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false); const userConfigState = useSelector((state: RootState) => state.userConfig); @@ -45,7 +44,6 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: } | null>(null); const handleProviderChange = (provider: string) => { - setLlmConfig(prev => ({ ...prev, LLM: provider @@ -73,8 +71,6 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: return 'OLLAMA_MODEL'; case 'custom': return 'CUSTOM_MODEL'; - case 'codex': - return 'CODEX_MODEL'; default: return ''; } @@ -94,6 +90,18 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: } }, [llmConfig.LLM]); + + + const getFieldValue = (field?: string) => { + if (!field) return ""; + return (llmConfig as Record)[field] || ""; + }; + + const currentApiKey = currentApiKeyField ? ((llmConfig as Record)[currentApiKeyField] as string || '') : ''; + const currentModel = currentModelField ? ((llmConfig as Record)[currentModelField] as string || '') : ''; + const currentOllamaUrl = llmConfig.OLLAMA_URL || ''; + const useCustomOllamaUrl = !!llmConfig.USE_CUSTOM_URL; + const getSelectedTextModel = (config: LLMConfig): string => { switch (config.LLM) { case 'openai': @@ -119,22 +127,11 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: return ''; }; - const getFieldValue = (field?: string) => { - if (!field) return ""; - return (llmConfig as Record)[field] || ""; - }; - - const currentApiKey = currentApiKeyField ? ((llmConfig as Record)[currentApiKeyField] as string || '') : ''; - const currentModel = currentModelField ? ((llmConfig as Record)[currentModelField] as string || '') : ''; - const currentOllamaUrl = llmConfig.OLLAMA_URL || ''; - const useCustomOllamaUrl = !!llmConfig.USE_CUSTOM_URL; - const fetchAvailableModels = async () => { if (llmConfig.LLM === 'openai' && !currentApiKey) return; if (llmConfig.LLM === 'google' && !currentApiKey) return; if (llmConfig.LLM === 'anthropic' && !currentApiKey) return; if (llmConfig.LLM === 'custom' && !llmConfig.CUSTOM_LLM_URL) return; - setModelsLoading(true); try { let response: Response; @@ -295,25 +292,15 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: setShowDownloadModal(false); } }; - - const handleSaveConfig = async () => { - trackEvent(MixpanelEvent.Home_SaveConfiguration_Button_Clicked, { pathname }); try { setSavingConfig(true); - // API: save config - trackEvent(MixpanelEvent.Home_SaveConfiguration_API_Call); - // API CALL: save config 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(); } } @@ -329,21 +316,30 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: text_provider_label: LLM_PROVIDERS[textProvider]?.label || textProvider || '', text_model: textModel, uses_chatgpt_login: textProvider === 'codex', + codex_model: llmConfig.CODEX_MODEL || '', + ollama_model: llmConfig.OLLAMA_MODEL || '', + ollama_uses_custom_url: !!llmConfig.USE_CUSTOM_URL, + custom_llm_url_set: Boolean((llmConfig.CUSTOM_LLM_URL || '').trim()), image_generation_enabled: imageGenerationEnabled, image_provider: imageProvider, image_provider_label: imageGenerationEnabled ? (IMAGE_PROVIDERS[imageProvider]?.label || imageProvider || '') : 'Image generation disabled', - image_quality: imageGenerationEnabled ? getSelectedImageQuality(llmConfig) : '', + image_quality: imageGenerationEnabled ? getSelectedImageQuality(llmConfig) : '' + }); + trackEvent(MixpanelEvent.Onboarding_Configuration_Saved, { + pathname, + text_provider: textProvider, + text_model: textModel, + image_generation_enabled: imageGenerationEnabled, + image_provider: imageProvider, }); toast.info("Configuration saved successfully"); - // Track navigation from -> to - trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/final onboarding step" }); setStep(3) // router.push("/upload"); } catch (error) { - toast.info(error instanceof Error ? error.message : "Failed to save configuration"); + toast.error(error instanceof Error ? error.message : "Failed to save configuration"); } finally { @@ -360,7 +356,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: useEffect(() => { if (llmConfig.LLM === 'ollama' && !modelsChecked && !modelsLoading) { - fetchAvailableModels(); + void fetchAvailableModels(); } }, [llmConfig.LLM, modelsChecked, modelsLoading]); @@ -372,24 +368,20 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:

Choose your content providers

Select the AI engines that will generate your slide text and visuals.

- {llmConfig.LLM === 'codex' && ( -
- { - const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field; - setLlmConfig((prev) => ({ - ...prev, - [normalizedField]: value, - })); - }} - /> -
- )} + { + const normalizedField = field === 'codex_model' ? 'CODEX_MODEL' : field; + setLlmConfig(prev => ({ + ...prev, + [normalizedField]: value + })); + }} + /> {/* Text Provider */}
-
-
+
@@ -421,7 +413,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: variant="outline" role="combobox" aria-expanded={openProviderSelect} - className=" h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between" + className=" h-12 px-4 py-4 outline-none border border-[#E8E8E9] rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between" >
@@ -563,9 +555,9 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: key={model.id} value={model.id} onSelect={(value) => { - setLlmConfig((prev) => ({ + setLlmConfig(prev => ({ ...prev, - CODEX_MODEL: value, + CODEX_MODEL: value })); setOpenModelSelect(false); }} @@ -641,7 +633,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep: (llmConfig.LLM === 'anthropic' && !currentApiKey) || (llmConfig.LLM === 'custom' && !llmConfig.CUSTOM_LLM_URL) } - className={`mt-4 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading + className={`mt-4 py-2.5 bg-[#EDEEEF] disabled:opacity-50 disabled:cursor-not-allowed px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading ? " border-gray-300 cursor-not-allowed text-gray-500" : " border-[#EDEEEF] text-[#101323] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20" }`} @@ -748,12 +740,12 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
{/* Image Provider */} -
- +
+
setLlmConfig(prev => ({ ...prev, DISABLE_IMAGE_GENERATION: !checked @@ -762,8 +754,8 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
-
-
+
image-markup @@ -964,7 +956,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
}
-
+
diff --git a/servers/nextjs/utils/mixpanel.ts b/servers/nextjs/utils/mixpanel.ts index 5cd8d86b..c8c1ac82 100644 --- a/servers/nextjs/utils/mixpanel.ts +++ b/servers/nextjs/utils/mixpanel.ts @@ -9,13 +9,37 @@ export enum MixpanelEvent { Navigation = 'Navigation', Home_SaveConfiguration_Button_Clicked = 'Home Save Configuration Button Clicked', Home_SaveConfiguration_API_Call = 'Home Save Configuration API Call', - Onboarding_Providers_Models_Selected = 'Onboarding Providers Models Selected', - Codex_SignIn_API_Call = 'Codex Sign In API Call', Home_CheckOllamaModelPulled_API_Call = 'Home Check Ollama Model Pulled API Call', Home_DownloadOllamaModel_API_Call = 'Home Download Ollama Model API Call', + + Onboarding_Providers_Models_Selected = 'Onboarding Providers Models Selected', + Onboarding_Configuration_Saved = 'Onboarding Configuration Saved', + Onboarding_Completed = 'Onboarding Completed', + + Codex_SignIn_API_Call = 'Codex Sign In API Call', + + Upload_Configuration_Invalid = 'Upload Configuration Invalid', + Upload_Generation_Started = 'Upload Generation Started', + Upload_Documents_Processed = 'Upload Documents Processed', + Upload_Outline_Generation_Requested = 'Upload Outline Generation Requested', Outline_Generate_Presentation_Button_Clicked = 'Outline Generate Presentation Button Clicked', Outline_Select_Template_Button_Clicked = 'Outline Select Template Button Clicked', Outline_Add_Slide_Button_Clicked = 'Outline Add Slide Button Clicked', + Outline_Template_Selected = 'Outline Template Selected', + Outline_Presentation_Generation_Started = 'Outline Presentation Generation Started', + + Presentation_Editor_Viewed = 'Presentation Editor Viewed', + Presentation_Mode_Entered = 'Presentation Mode Entered', + Presentation_Title_Updated = 'Presentation Title Updated', + Presentation_Slides_Reordered = 'Presentation Slides Reordered', + Presentation_Slide_Added = 'Presentation Slide Added', + Presentation_Slide_Updated = 'Presentation Slide Updated', + Presentation_Slide_Deleted = 'Presentation Slide Deleted', + Presentation_Theme_Changed = 'Presentation Theme Changed', + Presentation_Theme_Reset = 'Presentation Theme Reset', + Presentation_Export_Started = 'Presentation Export Started', + Presentation_Regenerated = 'Presentation Regenerated', + Presentation_Prepare_API_Call = 'Presentation Prepare API Call', Presentation_Stream_API_Call = 'Presentation Stream API Call', Group_Layout_Selected_Clicked = 'Group Layout Selected Clicked', @@ -40,6 +64,8 @@ export enum MixpanelEvent { 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', + Upload_GetStarted_Button_Clicked = 'Upload Get Started Button Clicked', + Upload_Validation_Failed = 'Upload Validation Failed', 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', @@ -53,6 +79,40 @@ export enum MixpanelEvent { ImageEditor_GenerateImage_API_Call = 'Image Editor Generate Image API Call', ImageEditor_UploadImage_API_Call = 'Image Editor Upload Image API Call', Header_ReGenerate_Button_Clicked = 'Header ReGenerate Button Clicked', + + Dashboard_Page_Viewed = 'Dashboard Page Viewed', + Dashboard_New_Presentation_Clicked = 'Dashboard New Presentation Clicked', + Dashboard_Presentation_Opened = 'Dashboard Presentation Opened', + Dashboard_Presentation_Deleted = 'Dashboard Presentation Deleted', + Dashboard_Create_New_Card_Clicked = 'Dashboard Create New Card Clicked', + + Sidebar_Navigation_Clicked = 'Sidebar Navigation Clicked', + + Templates_Page_Viewed = 'Templates Page Viewed', + Templates_Tab_Switched = 'Templates Tab Switched', + Templates_Inbuilt_Opened = 'Templates Inbuilt Opened', + Templates_Custom_Opened = 'Templates Custom Opened', + Templates_New_Template_Clicked = 'Templates New Template Clicked', + Templates_Build_Template_Clicked = 'Templates Build Template Clicked', + + Theme_Page_Viewed = 'Theme Page Viewed', + Theme_Selected = 'Theme Selected', + Theme_Saved = 'Theme Saved', + Theme_Deleted = 'Theme Deleted', + Theme_Font_Changed = 'Theme Font Changed', + Theme_Custom_Font_Uploaded = 'Theme Custom Font Uploaded', + Theme_Logo_Uploaded = 'Theme Logo Uploaded', + Theme_Tab_Switched = 'Theme Tab Switched', + Theme_New_Theme_Clicked = 'Theme New Theme Clicked', + Theme_Palette_Generated = 'Theme Palette Generated', + Theme_Editor_Opened = 'Theme Editor Opened', + Theme_Save_Started = 'Theme Save Started', + + CustomTemplate_Creation_Started = 'Custom Template Creation Started', + CustomTemplate_Creation_Completed = 'Custom Template Creation Completed', + CustomTemplate_Save_Started = 'Custom Template Save Started', + CustomTemplate_Saved = 'Custom Template Saved', + CustomTemplate_Save_Modal_Opened = 'Custom Template Save Modal Opened', } export type MixpanelProps = Record; @@ -76,24 +136,27 @@ async function ensureTelemetryStatus(): Promise { return window.__mixpanel_telemetry_enabled; } if (!trackingCheckPromise) { - trackingCheckPromise = fetch('/api/telemetry-status') - .then(async (res) => { - try { - const data = await res.json(); - const enabled = Boolean(data?.telemetryEnabled); - window.__mixpanel_telemetry_enabled = enabled; - return enabled; - } catch { - // If the API response is malformed, default to enabling tracking - window.__mixpanel_telemetry_enabled = true; - return true; + trackingCheckPromise = (async () => { + try { + let data; + // Check if running in Electron environment + if (typeof window !== 'undefined' && window.electron?.telemetryStatus) { + // Use Electron IPC handler + data = await window.electron.telemetryStatus(); + } else { + // Fallback to API route for web-based deployments + const res = await fetch('/api/telemetry-status'); + data = await res.json(); } - }) - .catch(() => { + const enabled = Boolean(data?.telemetryEnabled); + window.__mixpanel_telemetry_enabled = enabled; + return enabled; + } catch { // If the API call fails, default to enabling tracking window.__mixpanel_telemetry_enabled = true; return true; - }); + } + })(); } return trackingCheckPromise; } @@ -105,7 +168,11 @@ export function initMixpanel(): void { void ensureTelemetryStatus().then((enabled) => { if (!enabled) return; if (window.__mixpanel_initialized) return; - mixpanel.init(MIXPANEL_TOKEN as string, { track_pageview: false }); + mixpanel.init(MIXPANEL_TOKEN as string, { track_pageview: false, api_host: 'https://api-eu.mixpanel.com', }); + const appVersion = window.env?.APP_VERSION; + if (appVersion) { + mixpanel.register({ app_version: appVersion }); + } mixpanel.identify(mixpanel.get_distinct_id()); window.__mixpanel_initialized = true; }); @@ -164,7 +231,7 @@ export function setTelemetryEnabled(enabled: boolean): void { window.__mixpanel_telemetry_enabled = enabled; } trackingCheckPromise = null; - if (enabled && typeof window !== 'undefined' && !window.__mixpanel_initialized) { + if (enabled && !window?.__mixpanel_initialized) { initMixpanel(); } } @@ -178,5 +245,3 @@ export default { resetTelemetryCache, setTelemetryEnabled, }; - -