diff --git a/.gitignore b/.gitignore index e1475a9..d18cdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ __pycache__/ *.ntvs* *.njsproj *.sln -*.sw? \ No newline at end of file +*.sw? +.env diff --git a/package-lock.json b/package-lock.json index d144299..e6444c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "lucide-react": "^0.344.0", + "posthog-js": "^1.395.0", "react": "^18.3.1", "react-chartjs-2": "^5.2.0", "react-dom": "^18.3.1", @@ -2212,6 +2213,21 @@ "node": ">=14" } }, + "node_modules/@posthog/core": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.38.0.tgz", + "integrity": "sha512-QLrJh0hMVpEJXHNyiJR9YhvIO5tzLDedw4UHdvB+ub4fXHMtHCA8H44qgl11HWR3ajpjxtJ8hs3HyOGVF3Zc1w==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.391.1" + } + }, + "node_modules/@posthog/types": { + "version": "1.391.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.391.1.tgz", + "integrity": "sha512-ASwd7Nf4pViqdYRYaNRyPYRVKWa1CcHUAUWR0XeQJLGdNnsWACBwe0sSieb/cHnKsRXjRwO/23KIY83lm/Ccpw==", + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3785,7 +3801,6 @@ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", "hasInstallScript": true, "license": "MIT", - "optional": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -4100,7 +4115,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6365,6 +6379,44 @@ "dev": true, "license": "MIT" }, + "node_modules/posthog-js": { + "version": "1.395.0", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz", + "integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@posthog/core": "^1.38.0", + "@posthog/types": "^1.391.1", + "core-js": "^3.38.1", + "dompurify": "^3.3.2", + "fflate": "^0.4.8", + "preact": "^10.29.2", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.3.0" + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, + "node_modules/posthog-js/node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, + "node_modules/preact": { + "version": "10.29.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", + "integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6445,6 +6497,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 5ddaf6c..4916db0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "lucide-react": "^0.344.0", + "posthog-js": "^1.395.0", "react": "^18.3.1", "react-chartjs-2": "^5.2.0", "react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index 2119a81..3220ad2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,10 +6,12 @@ import ReportPage from './pages/ReportPage'; import HistoryPage from './pages/HistoryPage'; import AnalyticsDashboard from './pages/AnalyticsDashboard'; import NotFoundPage from './pages/NotFoundPage'; +import { PostHogProvider } from './utils/posthog/PostHogProvider'; function App() { return ( - + + }> } /> } /> @@ -20,6 +22,7 @@ function App() { } /> + ); } diff --git a/src/components/ScanUrlForm.tsx b/src/components/ScanUrlForm.tsx index e92169f..985d1df 100644 --- a/src/components/ScanUrlForm.tsx +++ b/src/components/ScanUrlForm.tsx @@ -3,6 +3,8 @@ import { Search } from 'lucide-react'; import { motion } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; import { useScanner } from '../hooks/useScanner'; +import { captureEvent } from '../utils/posthog/helpers'; +import { EVENTS } from '../utils/posthog/events'; interface ScanUrlFormProps { onSubmit?: (url: string) => void; @@ -39,7 +41,8 @@ const ScanUrlForm = ({ onSubmit, isScanning: externalIsScanning, disabled }: Sca try { new URL(processedUrl); setError(''); - + captureEvent(EVENTS.SCAN_SUBMITTED, { url: processedUrl }); + if (onSubmit) { onSubmit(processedUrl); } else { diff --git a/src/pages/HistoryPage.tsx b/src/pages/HistoryPage.tsx index 3fdbebf..2e6f3bb 100644 --- a/src/pages/HistoryPage.tsx +++ b/src/pages/HistoryPage.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from 'react'; +import { captureEvent } from '../utils/posthog/helpers'; +import { EVENTS } from '../utils/posthog/events'; import { useNavigate } from 'react-router-dom'; import { motion } from 'framer-motion'; import { @@ -35,7 +37,6 @@ const HistoryPage = () => { const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [selectedScans, setSelectedScans] = useState([]); const [successMessage, setSuccessMessage] = useState(null); - // Fetch scan history from backend const fetchScanHistory = async () => { try { @@ -126,7 +127,14 @@ const HistoryPage = () => { // Handle search const handleSearch = (e: React.ChangeEvent) => { - setSearchTerm(e.target.value); + const value = e.target.value; + setSearchTerm(value); + if (value.length > 2) { + captureEvent(EVENTS.HISTORY_SEARCHED, { + search_term: value, + result_count: scanHistory.filter(s => s.url.toLowerCase().includes(value.toLowerCase())).length, + }); + } }; // Toggle sort order @@ -162,6 +170,7 @@ const HistoryPage = () => { const success = await deleteScansFromBackend(selectedScans); if (success) { + captureEvent(EVENTS.HISTORY_SCANS_DELETED, { deleted_count: selectedScans.length }); // Remove from local state setScanHistory(prev => prev.filter(scan => !selectedScans.includes(scan.id))); setSelectedScans([]); diff --git a/src/pages/ReportPage.tsx b/src/pages/ReportPage.tsx index a28b830..8ff0010 100644 --- a/src/pages/ReportPage.tsx +++ b/src/pages/ReportPage.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState, useRef } from "react"; +import { captureEvent, captureException } from "../utils/posthog/helpers"; +import { EVENTS } from "../utils/posthog/events"; import { useLocation, useParams, useNavigate } from "react-router-dom"; import { AiOutlineFileDone, @@ -331,6 +333,7 @@ const ReportPage: React.FC = () => { const copyReportLink = async () => { try { await navigator.clipboard.writeText(window.location.href); + captureEvent(EVENTS.REPORT_LINK_COPIED, { scan_id: scanId ?? id }); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (error) { @@ -380,6 +383,11 @@ const ReportPage: React.FC = () => { : hookResult.date, }; setReportData(normalizedResult); + captureEvent(EVENTS.SCAN_COMPLETED, { + scan_id: currentScanId, + url: hookResult.url, + accessibility_score: hookResult.score ?? (hookResult as { results?: { score?: number } }).results?.score, + }); setLoading(false); scanInProgress.current = false; return; @@ -459,6 +467,12 @@ const ReportPage: React.FC = () => { throw new Error("No valid scan ID or URL provided for report generation."); } catch (err: unknown) { console.error("Scan error caught:", err); + captureException(err, { scan_id: currentScanId, url: urlFromQuery }); + captureEvent(EVENTS.SCAN_FAILED, { + scan_id: currentScanId, + url: urlFromQuery, + error_message: err instanceof Error ? err.message : String(err), + }); setReportData(null); if (currentScanId && (currentScanId.startsWith("http") || currentScanId.includes("."))) { @@ -491,9 +505,14 @@ const ReportPage: React.FC = () => { } }, [scanId, id, urlFromQuery, fetchReport, navigate, error]); - - - + useEffect(() => { + if (!reportData) return; + captureEvent(EVENTS.REPORT_VIEWED, { + scan_id: reportData._id ?? reportData.id ?? scanId ?? id, + url: reportData.url, + accessibility_score: reportData.score, + }); + }, [reportData]); const getScoreBadgeColor = (score: number): string => { if (score >= 90) return "bg-green-100 text-green-800"; @@ -581,6 +600,7 @@ const ReportPage: React.FC = () => { heightLeft -= pageHeight; } pdf.save("accessibility-report.pdf"); + captureEvent(EVENTS.REPORT_DOWNLOADED, { scan_id: scanId ?? id, format: "pdf" }); }; const downloadJSON = () => { @@ -594,6 +614,7 @@ const ReportPage: React.FC = () => { link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); + captureEvent(EVENTS.REPORT_DOWNLOADED, { scan_id: scanId ?? id, format: "json" }); }; return ( diff --git a/src/utils/posthog/PostHogProvider.tsx b/src/utils/posthog/PostHogProvider.tsx new file mode 100644 index 0000000..d6edfbd --- /dev/null +++ b/src/utils/posthog/PostHogProvider.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import posthog from 'posthog-js'; + +posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { + api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, + capture_pageview: false, + loaded: (ph) => { + if (import.meta.env.DEV) ph.debug(); + }, +}); + +export function PostHogProvider({ children }: { children: React.ReactNode }) { + const location = useLocation(); + + useEffect(() => { + posthog.capture('$pageview'); + }, [location]); + + return <>{children}; +} diff --git a/src/utils/posthog/events.ts b/src/utils/posthog/events.ts new file mode 100644 index 0000000..ed0ee22 --- /dev/null +++ b/src/utils/posthog/events.ts @@ -0,0 +1,11 @@ +export const EVENTS = { + SCAN_SUBMITTED: "scan_submitted", + SCAN_COMPLETED: "scan_completed", + SCAN_FAILED: "scan_failed", + REPORT_VIEWED: "report_viewed", + REPORT_DOWNLOADED: "report_downloaded", + REPORT_LINK_COPIED: "report_link_copied", +HISTORY_SCANS_DELETED: "history_scans_deleted", + HISTORY_SEARCHED: "history_searched", + REPORT_ISSUE_FIX_CLICKED: "report_issue_fix_clicked", +} as const; diff --git a/src/utils/posthog/helpers.ts b/src/utils/posthog/helpers.ts new file mode 100644 index 0000000..3c16444 --- /dev/null +++ b/src/utils/posthog/helpers.ts @@ -0,0 +1,34 @@ +import posthog from "posthog-js"; + +/** + * Associates the current session with a user identity in PostHog. + * @param userId - Unique identifier for the user + * @param traits - Optional properties to attach to the user profile + */ +export function identifyUser(userId: string, traits?: Record) { + posthog.identify(userId, traits); +} + +/** + * Tracks a named event with optional metadata. + * @param event - Event name + * @param properties - Optional key-value pairs describing the event + */ +export function captureEvent( + event: string, + properties?: Record, +) { + posthog.capture(event, properties); +} + +/** + * Reports an exception to PostHog error tracking. + * @param error - The caught error or unknown value + * @param properties - Optional additional context + */ +export function captureException( + error: unknown, + properties?: Record, +) { + posthog.captureException(error, properties); +}