feat: add Umami self-hosted analytics with custom event tracking
- Root layout: load Umami script (afterInteractive) — covers all pages including
SPA navigation auto-tracking
- Marketing layout: remove Plausible script (Umami now covers marketing pages too)
- src/lib/analytics.ts: type-safe track() wrapper + typed helpers for each event;
window.umami declared globally; safe no-op on SSR/ad-block
- Custom events wired:
log-created { logType } — LogModal on successful save
garment-added — wardrobe/add after save
memory-added — memories after upload pipeline completes
growth-logged — growth page after measurement saved
pwa-installed — InstallPrompt when Android prompt accepted
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cbbe8f24ac
commit
deaa1810d7
8 changed files with 81 additions and 9 deletions
|
|
@ -7,6 +7,7 @@ import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
|
|||
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
|
||||
import { formatAge } from "@/lib/formatting";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
import { trackGrowthLogged } from "@/lib/analytics";
|
||||
import type { GrowthRecord, Goal } from "@/types";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
|
|
@ -116,6 +117,7 @@ export default function GrowthPage() {
|
|||
setSaveError(err.error || "Failed to save");
|
||||
return;
|
||||
}
|
||||
trackGrowthLogged();
|
||||
resetForm();
|
||||
fetchGrowthData();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useFamily } from "@/app/FamilyProvider";
|
|||
import { Button, ConfirmDialog, Modal } from "@/components/ui";
|
||||
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
|
||||
import { formatBytes } from "@/lib/format-bytes";
|
||||
import { trackMemoryAdded } from "@/lib/analytics";
|
||||
|
||||
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
|
||||
function resolveContentType(file: File): string {
|
||||
|
|
@ -213,6 +214,7 @@ export default function MemoriesPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
trackMemoryAdded();
|
||||
// Optimistic grid update
|
||||
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
|
||||
setMemories(prev => [{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||
import Image from "next/image";
|
||||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button } from "@/components/ui";
|
||||
import { trackGarmentAdded } from "@/lib/analytics";
|
||||
import { GARMENT_CATEGORIES, GARMENT_SIZE_ORDER } from "@/db/schema/wardrobe";
|
||||
|
||||
type Step = "capture" | "form";
|
||||
|
|
@ -224,6 +225,7 @@ export default function AddGarmentPage() {
|
|||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
trackGarmentAdded();
|
||||
handleAddAnother();
|
||||
} catch (err) {
|
||||
setError(`Save failed: ${err}`);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { MarketingNav } from "@/components/marketing/MarketingNav";
|
||||
import Script from "next/script";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
|
|
@ -32,15 +31,8 @@ export default function MarketingLayout({
|
|||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Privacy-respecting analytics — no cookies, no tracking */}
|
||||
<Script
|
||||
defer
|
||||
data-domain={process.env.NEXT_PUBLIC_APP_URL?.replace("https://", "").replace("http://", "")}
|
||||
src="https://plausible.io/js/script.js"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
|
||||
{/* Scroll-reveal nav — appears after scrolling past hero */}
|
||||
{/* Analytics now provided globally by Umami in root layout.tsx */}
|
||||
<MarketingNav />
|
||||
|
||||
<main>{children}</main>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono, Caveat } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { SITE_URL, SITE_NAME } from "@/lib/seo";
|
||||
import "./globals.css";
|
||||
|
||||
|
|
@ -69,6 +70,12 @@ export default function RootLayout({
|
|||
<html lang="en-IN" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} min-h-full antialiased`}>
|
||||
{children}
|
||||
{/* Self-hosted Umami analytics — privacy-first, no cookies, GDPR-compliant */}
|
||||
<Script
|
||||
src="https://analytics.manohargupta.com/script.js"
|
||||
data-website-id="79444c19-ee31-4fab-baf5-f4e61098eeba"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { trackPwaInstalled } from "@/lib/analytics";
|
||||
|
||||
// Extend the BeforeInstallPromptEvent type (not in standard lib)
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
|
|
@ -132,6 +133,7 @@ export function InstallPrompt() {
|
|||
const { outcome } = await deferred.userChoice;
|
||||
if (outcome === "accepted") {
|
||||
localStorage.setItem(KEY_INSTALLED, "1");
|
||||
trackPwaInstalled();
|
||||
setDeferred(null);
|
||||
setShow(false);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Button, Modal, Select, Input } from "@/components/ui";
|
||||
import { addToOfflineQueue } from "@/lib/offline-queue";
|
||||
import { trackLogCreated } from "@/lib/analytics";
|
||||
import type { LogType } from "@/types";
|
||||
|
||||
export type { LogType };
|
||||
|
|
@ -111,6 +112,7 @@ export function LogModal({ type, childId, onClose, onSaved, smartDefault }: Prop
|
|||
if (!res.ok && !navigator.onLine) {
|
||||
addToOfflineQueue({ type, data });
|
||||
}
|
||||
trackLogCreated(type as "feed" | "sleep" | "diaper");
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch {
|
||||
|
|
|
|||
63
src/lib/analytics.ts
Normal file
63
src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* analytics.ts — thin wrapper around Umami's window.umami.track().
|
||||
*
|
||||
* Why a wrapper?
|
||||
* - window.umami is undefined during SSR and before the script loads
|
||||
* - gives us a typed, central place to see all tracked events
|
||||
* - safe no-op in development if the script isn't loaded
|
||||
*
|
||||
* Usage:
|
||||
* import { track } from "@/lib/analytics";
|
||||
* track("log-created", { logType: "feed" });
|
||||
*
|
||||
* Umami dashboard → Events tab shows each event name + property breakdown.
|
||||
*/
|
||||
|
||||
// Extend Window with Umami's runtime API
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string, data?: Record<string, string | number | boolean>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type EventData = Record<string, string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Fire a custom Umami analytics event.
|
||||
* Safe to call anywhere — no-ops if Umami hasn't loaded yet (SSR, dev, ad-blockers).
|
||||
*/
|
||||
export function track(event: string, data?: EventData): void {
|
||||
try {
|
||||
window.umami?.track(event, data);
|
||||
} catch {
|
||||
// Swallow — analytics must never break the app
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Typed event helpers ──────────────────────────────────────────────────────
|
||||
// These keep event names consistent across the codebase and show up as
|
||||
// named events in the Umami dashboard.
|
||||
|
||||
/** Feed, diaper, or sleep log saved */
|
||||
export const trackLogCreated = (logType: "feed" | "sleep" | "diaper") =>
|
||||
track("log-created", { logType });
|
||||
|
||||
/** Garment added to wardrobe */
|
||||
export const trackGarmentAdded = () => track("garment-added");
|
||||
|
||||
/** Memory photo uploaded */
|
||||
export const trackMemoryAdded = () => track("memory-added");
|
||||
|
||||
/** Growth measurement recorded */
|
||||
export const trackGrowthLogged = () => track("growth-logged");
|
||||
|
||||
/** PWA installed from browser prompt */
|
||||
export const trackPwaInstalled = () => track("pwa-installed");
|
||||
|
||||
/** AI chat message sent */
|
||||
export const trackAiChat = () => track("ai-chat-sent");
|
||||
|
||||
/** Vaccine marked as given */
|
||||
export const trackVaccineGiven = () => track("vaccine-given");
|
||||
Loading…
Add table
Reference in a new issue