diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/app/package.json b/app/package.json index f4ef04d5a..d48456387 100644 --- a/app/package.json +++ b/app/package.json @@ -49,6 +49,7 @@ "html-to-image": "^1.11.13", "jsonp": "^0.2.1", "lucide-react": "^0.575.0", + "posthog-js": "^1.364.5", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/app/src/components/common/ErrorBoundary.tsx b/app/src/components/common/ErrorBoundary.tsx index 9eb754065..892e4e272 100644 --- a/app/src/components/common/ErrorBoundary.tsx +++ b/app/src/components/common/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import { captureReactBoundaryException } from '@/utils/errorTracking'; export interface ErrorBoundaryProps { children: ReactNode; @@ -37,6 +38,7 @@ export class ErrorBoundary extends Component void; onNumChildrenChange: (num: number) => void; disabled?: boolean; + trackingMode?: 'report' | 'standalone'; } export default function HouseholdBuilderForm({ @@ -68,6 +74,7 @@ export default function HouseholdBuilderForm({ onMaritalStatusChange, onNumChildrenChange, disabled = false, + trackingMode = 'report', }: HouseholdBuilderFormProps) { // State for custom variables const [selectedVariables, setSelectedVariables] = useState([]); @@ -138,6 +145,14 @@ export default function HouseholdBuilderForm({ [householdSearchValue, allInputVariables] ); + useEffect(() => { + trackHouseholdBuilderOpened({ + countryId: household.countryId, + year, + mode: trackingMode, + }); + }, [household.countryId, trackingMode, year]); + // Get variables for a specific person (custom only, not basic inputs) const getPersonVariables = (personName: string): string[] => { const personData = household.householdData.people[personName]; @@ -198,10 +213,25 @@ export default function HouseholdBuilderForm({ const newHousehold = addVariableToEntity(household, variable.name, metadata, year, person); onChange(newHousehold); + const nextSelectedVariables = selectedVariables.includes(variable.name) + ? selectedVariables + : [...selectedVariables, variable.name]; + if (!selectedVariables.includes(variable.name)) { - setSelectedVariables([...selectedVariables, variable.name]); + setSelectedVariables(nextSelectedVariables); } + trackHouseholdVariableAdded({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: variable.name, + variableLabel: variable.label, + entityScope: isPerson ? 'person' : 'household', + entityName: isPerson ? person : undefined, + selectedVariableCount: nextSelectedVariables.length, + }); + setActivePersonSearch(null); setPersonSearchValue(''); setIsPersonSearchFocused(false); @@ -223,9 +253,23 @@ export default function HouseholdBuilderForm({ ); // If no one else has it, remove from selectedVariables + const nextSelectedVariables = stillUsedByOthers + ? selectedVariables + : selectedVariables.filter((v) => v !== varName); + if (!stillUsedByOthers) { - setSelectedVariables(selectedVariables.filter((v) => v !== varName)); + setSelectedVariables(nextSelectedVariables); } + + trackHouseholdVariableRemoved({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: varName, + entityScope: 'person', + entityName: person, + selectedVariableCount: nextSelectedVariables.length, + }); }; // Handle opening household search @@ -272,10 +316,24 @@ export default function HouseholdBuilderForm({ ); } onChange(newHousehold); + const nextSelectedVariables = selectedVariables.includes(variable.name) + ? selectedVariables + : [...selectedVariables, variable.name]; if (!selectedVariables.includes(variable.name)) { - setSelectedVariables([...selectedVariables, variable.name]); + setSelectedVariables(nextSelectedVariables); } + trackHouseholdVariableAdded({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: variable.name, + variableLabel: variable.label, + entityScope: isPerson ? 'person' : 'household', + entityName: isPerson ? 'all_people' : undefined, + selectedVariableCount: nextSelectedVariables.length, + }); + setIsHouseholdSearchActive(false); setHouseholdSearchValue(''); setIsHouseholdSearchFocused(false); @@ -285,7 +343,17 @@ export default function HouseholdBuilderForm({ const handleRemoveHouseholdVariable = (varName: string) => { const newHousehold = removeVariable(household, varName, metadata); onChange(newHousehold); - setSelectedVariables(selectedVariables.filter((v) => v !== varName)); + const nextSelectedVariables = selectedVariables.filter((v) => v !== varName); + setSelectedVariables(nextSelectedVariables); + + trackHouseholdVariableRemoved({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: varName, + entityScope: 'household', + selectedVariableCount: nextSelectedVariables.length, + }); }; return ( diff --git a/app/src/hooks/useCreateHousehold.ts b/app/src/hooks/useCreateHousehold.ts index e3480d646..ef1a87b7a 100644 --- a/app/src/hooks/useCreateHousehold.ts +++ b/app/src/hooks/useCreateHousehold.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createHousehold } from '@/api/household'; import { MOCK_USER_ID } from '@/constants'; import { countryIds } from '@/libs/countries'; +import { captureApiException } from '@/utils/errorTracking'; import { useCreateHouseholdAssociation } from './useUserHousehold'; export function useCreateHousehold(householdLabel?: string) { @@ -22,6 +23,12 @@ export function useCreateHousehold(householdLabel?: string) { }); } catch (error) { console.error('Household created but association failed:', error); + captureApiException(error, { + source: 'household_association', + country_id: variables.country_id, + household_id: data.result.household_id, + has_label: Boolean(householdLabel), + }); } }, }); diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index 9a6b377ae..5d0c3645a 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createPolicy } from '@/api/policy'; import { MOCK_USER_ID } from '@/constants'; import { PolicyCreationPayload } from '@/types/payloads'; +import { captureApiException } from '@/utils/errorTracking'; import { useCurrentCountry } from './useCurrentCountry'; import { useCreatePolicyAssociation } from './useUserPolicy'; @@ -26,6 +27,12 @@ export function useCreatePolicy(policyLabel?: string) { }); } catch (error) { console.error('Policy created but association failed:', error); + captureApiException(error, { + source: 'policy_association', + country_id: countryId, + policy_id: data.result.policy_id, + has_label: Boolean(policyLabel), + }); } }, }); diff --git a/app/src/hooks/useCreateReport.ts b/app/src/hooks/useCreateReport.ts index 7a7b4d5ef..38deeac82 100644 --- a/app/src/hooks/useCreateReport.ts +++ b/app/src/hooks/useCreateReport.ts @@ -8,6 +8,8 @@ import { Geography } from '@/types/ingredients/Geography'; import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { ReportCreationPayload } from '@/types/payloads'; +import { trackReportCreated } from '@/utils/analytics'; +import { captureCalculationException, captureCalculatorException } from '@/utils/errorTracking'; interface CreateReportAndBeginCalculationParams { countryId: (typeof countryIds)[number]; @@ -83,12 +85,23 @@ export function useCreateReport(reportLabel?: string) { try { const { report, simulations, populations } = result; const reportIdStr = String(report.id); + const simulationList = [simulations?.simulation1, simulations?.simulation2].filter( + (simulation): simulation is Simulation => Boolean(simulation) + ); // Invalidate report association queries so the Reports page picks up the new report queryClient.invalidateQueries({ queryKey: reportAssociationKeys.all }); // Cache the report data using consistent key structure queryClient.setQueryData(reportKeys.byId(reportIdStr), report); + trackReportCreated({ + countryId: report.countryId, + report: { + ...report, + id: reportIdStr, + }, + simulations: simulationList, + }); // Determine calculation type from simulation const simulation1 = simulations?.simulation1; @@ -147,6 +160,13 @@ export function useCreateReport(reportLabel?: string) { `[useCreateReport] Failed to start calculation for simulation ${sim.id}:`, error ); + captureCalculationException(error, { + source: 'use_create_report_household_start', + country_id: report.countryId, + year: report.year, + report_id: reportIdStr, + simulation_id: sim.id, + }); }); } } else { @@ -173,6 +193,10 @@ export function useCreateReport(reportLabel?: string) { } } catch (error) { console.error('[useCreateReport] Post-creation tasks failed:', error); + captureCalculatorException(error, { + source: 'use_create_report_on_success', + report_label: reportLabel, + }); } finally { if (import.meta.env.DEV) { (window as any).__journeyProfiler?.markEnd('report-onSuccess', 'render'); diff --git a/app/src/libs/calculations/CalcOrchestrator.ts b/app/src/libs/calculations/CalcOrchestrator.ts index 56f368af5..bfac40f31 100644 --- a/app/src/libs/calculations/CalcOrchestrator.ts +++ b/app/src/libs/calculations/CalcOrchestrator.ts @@ -1,7 +1,12 @@ import { QueryClient, QueryObserver } from '@tanstack/react-query'; import { calculationQueries } from '@/libs/queries/calculationQueries'; import type { CalcMetadata, CalcParams, CalcStartConfig, CalcStatus } from '@/types/calculation'; -import { trackSimulationCompleted } from '@/utils/analytics'; +import { + trackCalculationFailed, + trackCalculationStarted, + trackSimulationCompleted, +} from '@/utils/analytics'; +import { captureCalculationException } from '@/utils/errorTracking'; import type { CalcOrchestratorManager } from './CalcOrchestratorManager'; import { ResultPersister } from './ResultPersister'; @@ -59,6 +64,7 @@ export class CalcOrchestrator { // Build metadata and params const metadata = this.buildMetadata(config); const params = this.buildParams(config); + trackCalculationStarted({ config }); // Create query options (includes refetchInterval from strategy) const queryOptions = @@ -80,7 +86,32 @@ export class CalcOrchestrator { } // Execute initial queryFn - const initialStatus = await queryOptions.queryFn(); + let initialStatus: CalcStatus; + + try { + initialStatus = await queryOptions.queryFn(); + } catch (error) { + trackCalculationFailed({ + calcId: config.calcId, + targetType: config.targetType, + countryId: config.countryId, + year: config.year, + calcType: metadata.calcType, + reportId: config.reportId, + durationMs: Date.now() - metadata.startedAt, + error, + config, + }); + captureCalculationException(error, { + source: 'calc_orchestrator_query_fn', + calc_id: config.calcId, + target_type: config.targetType, + country_id: config.countryId, + year: config.year, + report_id: config.reportId, + }); + throw error; + } // Set result in cache this.queryClient.setQueryData(queryOptions.queryKey, initialStatus); @@ -88,7 +119,15 @@ export class CalcOrchestrator { // CRITICAL DECISION POINT: Household vs Economy if (initialStatus.status === 'complete') { // HOUSEHOLD CASE: Calculation completed synchronously - trackSimulationCompleted({ calcType: metadata.calcType, countryId: config.countryId }); + trackSimulationCompleted({ + calcType: metadata.calcType, + countryId: config.countryId, + year: config.year, + calcId: config.calcId, + targetType: config.targetType, + reportId: config.reportId, + durationMs: Date.now() - metadata.startedAt, + }); await this.resultPersister.persist(initialStatus, config.countryId, config.year); // Notify manager to cleanup this orchestrator @@ -157,7 +196,15 @@ export class CalcOrchestrator { // Handle completion if (status.status === 'complete' && status.result) { - trackSimulationCompleted({ calcType: _metadata.calcType, countryId }); + trackSimulationCompleted({ + calcType: _metadata.calcType, + countryId, + year, + calcId, + targetType: _metadata.targetType, + reportId: _metadata.reportId, + durationMs: Date.now() - _metadata.startedAt, + }); this.resultPersister .persist(status, countryId, year) .catch((error) => { @@ -179,6 +226,24 @@ export class CalcOrchestrator { // Handle error if (status.status === 'error') { console.error('[CalcOrchestrator] Calculation error:', status.error); + trackCalculationFailed({ + calcId, + targetType: _metadata.targetType, + countryId, + year, + calcType: _metadata.calcType, + reportId: _metadata.reportId, + durationMs: Date.now() - _metadata.startedAt, + error: status.error, + }); + captureCalculationException(status.error, { + source: 'calc_orchestrator_polling', + calc_id: calcId, + target_type: _metadata.targetType, + country_id: countryId, + year, + report_id: _metadata.reportId, + }); unsubscribe(); this.currentUnsubscribe = null; diff --git a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts index 71a7a10cc..89baa9801 100644 --- a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts +++ b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts @@ -6,6 +6,7 @@ import type { HouseholdReportConfig, SimulationConfig } from '@/types/calculatio import type { HouseholdData } from '@/types/ingredients/Household'; import type { Report } from '@/types/ingredients/Report'; import { cacheMonitor } from '@/utils/cacheMonitor'; +import { captureCalculationException } from '@/utils/errorTracking'; import { HouseholdProgressCoordinator } from './HouseholdProgressCoordinator'; import { buildHouseholdReportOutput } from './householdReportUtils'; import { HouseholdSimCalculator } from './HouseholdSimCalculator'; @@ -93,6 +94,11 @@ export class HouseholdReportOrchestrator { }) .catch((error) => { console.error('[HouseholdReportOrchestrator] Error in parallel execution:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_parallel', + country_id: countryId, + report_id: reportId, + }); // Mark report as error return this.markReportError(config.report, countryId, reportId); @@ -147,6 +153,12 @@ export class HouseholdReportOrchestrator { await this.persistSimulation(countryId, simulationId, result); } catch (error) { console.error('[HouseholdReportOrchestrator] Simulation failed:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_simulation', + country_id: countryId, + report_id: reportId, + simulation_id: simulationId, + }); // Notify progress coordinator that this simulation failed progressCoordinator.failSimulation(simulationId); @@ -256,6 +268,11 @@ export class HouseholdReportOrchestrator { } } catch (error) { console.error('[HouseholdReportOrchestrator] Failed to mark report complete:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_complete', + country_id: countryId, + report_id: reportId, + }); } } @@ -291,6 +308,11 @@ export class HouseholdReportOrchestrator { } } catch (error) { console.error('[HouseholdReportOrchestrator] Failed to mark report error:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_mark_error', + country_id: countryId, + report_id: reportId, + }); } } } diff --git a/app/src/pages/report-output/HouseholdReportOutput.tsx b/app/src/pages/report-output/HouseholdReportOutput.tsx index 178754608..5dd4649b5 100644 --- a/app/src/pages/report-output/HouseholdReportOutput.tsx +++ b/app/src/pages/report-output/HouseholdReportOutput.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useSimulationProgressDisplay } from '@/hooks/household'; import type { Household } from '@/types/ingredients/Household'; import type { Policy } from '@/types/ingredients/Policy'; @@ -7,6 +7,7 @@ import type { Simulation } from '@/types/ingredients/Simulation'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; import type { UserHouseholdPopulation } from '@/types/ingredients/UserPopulation'; import type { UserSimulation } from '@/types/ingredients/UserSimulation'; +import { trackReportOutputSubpageViewed, trackReportOutputViewed } from '@/utils/analytics'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; import { convertPoliciesToV1Format } from '@/utils/reproducibilityCode'; import { getDisplayStatus } from '@/utils/statusMapping'; @@ -153,6 +154,34 @@ export function HouseholdReportOutput({ }: HouseholdReportOutputProps) { const normalizedSubpage = resolveDefaultReportOutputSubpage('household', subpage); + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputViewed({ + report, + simulations, + calcType: 'household', + subpage: normalizedSubpage, + activeView, + }); + }, [report?.id]); + + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputSubpageViewed({ + report, + simulations, + calcType: 'household', + subpage: normalizedSubpage, + activeView, + }); + }, [activeView, normalizedSubpage, report, simulations]); + // Build view model (memoized - recomputes only when props change) const viewModel = useMemo( () => new HouseholdReportViewModel(report, simulations, userSimulations, userPolicies), diff --git a/app/src/pages/report-output/SocietyWideReportOutput.tsx b/app/src/pages/report-output/SocietyWideReportOutput.tsx index 8bc36867d..759780acd 100644 --- a/app/src/pages/report-output/SocietyWideReportOutput.tsx +++ b/app/src/pages/report-output/SocietyWideReportOutput.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideCalculation'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; @@ -13,6 +13,7 @@ import type { Simulation } from '@/types/ingredients/Simulation'; import type { UserPolicy } from '@/types/ingredients/UserPolicy'; import type { UserGeographyPopulation } from '@/types/ingredients/UserPopulation'; import type { UserSimulation } from '@/types/ingredients/UserSimulation'; +import { trackReportOutputSubpageViewed, trackReportOutputViewed } from '@/utils/analytics'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; import { convertPoliciesToV1Format } from '@/utils/reproducibilityCode'; import { getDisplayStatus } from '@/utils/statusMapping'; @@ -159,6 +160,34 @@ export function SocietyWideReportOutput({ }: SocietyWideReportOutputProps) { const normalizedSubpage = resolveDefaultReportOutputSubpage('societyWide', subpage); + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputViewed({ + report, + simulations, + calcType: 'societyWide', + subpage: normalizedSubpage, + activeView, + }); + }, [report?.id]); + + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputSubpageViewed({ + report, + simulations, + calcType: 'societyWide', + subpage: normalizedSubpage, + activeView, + }); + }, [activeView, normalizedSubpage, report, simulations]); + // Read datasets from metadata for the reproduce tab const datasets = useSelector((state: RootState) => state.metadata.economyOptions?.datasets); diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index d47692879..719d596ba 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -13,6 +13,7 @@ import { IconPlayerPlay } from '@tabler/icons-react'; import { CURRENT_YEAR } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { trackSocietyWideBuilderOpened } from '@/utils/analytics'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; import { getReportOutputPath } from '@/utils/reportRouting'; import { ReportBuilderShell, SimulationBlockFull } from './components'; @@ -43,6 +44,13 @@ export default function ReportBuilderPage() { ingredientType: 'policy', }); + useEffect(() => { + trackSocietyWideBuilderOpened({ + countryId, + year: CURRENT_YEAR, + }); + }, [countryId]); + // Submission logic (extracted hook) const { handleSubmit, isSubmitting, isReportConfigured } = useReportSubmission({ reportState, diff --git a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx index f641a77db..5344ec7fd 100644 --- a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx +++ b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx @@ -20,7 +20,9 @@ import { } from '@/components/ui'; import { CURRENT_YEAR } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { getTaxYears } from '@/libs/metadataUtils'; +import { trackReportYearSelected } from '@/utils/analytics'; import { FONT_SIZES } from '../constants'; import type { ReportBuilderState } from '../types'; @@ -43,6 +45,7 @@ interface ReportMetaPanelProps { } export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: ReportMetaPanelProps) { + const countryId = useCurrentCountry(); const yearOptions = useSelector(getTaxYears); const [isEditingLabel, setIsEditingLabel] = useState(false); const [labelInput, setLabelInput] = useState(''); @@ -219,9 +222,19 @@ export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: Rep setEmail(event.currentTarget.value)} - disabled={status === 'loading'} + disabled={status === "loading"} /> {message && (

{message} @@ -192,23 +216,27 @@ export default function Footer() { data-testid="site-footer" className="tw:w-full" style={{ - padding: `${spacing['4xl']} ${spacing['5xl']}`, + padding: `${spacing["4xl"]} ${spacing["5xl"]}`, background: `linear-gradient(to right, ${colors.primary[800]}, ${colors.primary[600]})`, }} > - PolicyEngine + PolicyEngine

{[ - { href: CONTACT_LINKS.about, text: 'About us' }, - { href: CONTACT_LINKS.donate, text: 'Donate' }, - { href: CONTACT_LINKS.privacy, text: 'Privacy policy' }, - { href: CONTACT_LINKS.terms, text: 'Terms and conditions' }, + { href: CONTACT_LINKS.about, text: "About us" }, + { href: CONTACT_LINKS.donate, text: "Donate" }, + { href: CONTACT_LINKS.privacy, text: "Privacy policy" }, + { href: CONTACT_LINKS.terms, text: "Terms and conditions" }, ].map(({ href, text }) => ( ))}

- © {new Date().getFullYear()} PolicyEngine. All rights reserved. + © {new Date().getFullYear()} PolicyEngine. All rights + reserved.

diff --git a/website/src/components/Header.tsx b/website/src/components/Header.tsx index 9ce05fcd5..c66ec6152 100644 --- a/website/src/components/Header.tsx +++ b/website/src/components/Header.tsx @@ -15,6 +15,7 @@ import { typography, } from "@policyengine/design-system/tokens"; import { useCountryId } from "@/hooks/useCountryId"; +import { trackCountrySwitched } from "@/lib/posthog-events"; const PolicyEngineLogo = "/assets/logos/policyengine/white.svg"; @@ -258,6 +259,11 @@ function CountrySelector() { const handleCountryChange = useCallback( (newCountryId: string) => { setOpen(false); + trackCountrySwitched({ + from_country_id: countryId, + to_country_id: newCountryId, + pathname, + }); const pathParts = pathname.split("/").filter(Boolean); if (pathParts.length > 0) { pathParts[0] = newCountryId; @@ -266,7 +272,7 @@ function CountrySelector() { router.push(`/${newCountryId}`); } }, - [pathname, router], + [countryId, pathname, router], ); useEffect(() => { @@ -429,7 +435,13 @@ const mobileNavLinkStyle: React.CSSProperties = { display: "block", }; -function MobileNavLink({ item, onClose }: { item: NavItemSetup; onClose: () => void }) { +function MobileNavLink({ + item, + onClose, +}: { + item: NavItemSetup; + onClose: () => void; +}) { const Tag = item.external ? "a" : Link; return ( @@ -582,8 +594,18 @@ export default function Header() { const navItems: NavItemSetup[] = [ { label: "Research", href: `/${countryId}/research`, hasDropdown: false }, - { label: "Model", href: `/${countryId}/model`, hasDropdown: false, external: true }, - { label: "API", href: `/${countryId}/api`, hasDropdown: false, external: true }, + { + label: "Model", + href: `/${countryId}/model`, + hasDropdown: false, + external: true, + }, + { + label: "API", + href: `/${countryId}/api`, + hasDropdown: false, + external: true, + }, { label: "About", hasDropdown: true, diff --git a/website/src/components/home/HeroCTA.tsx b/website/src/components/home/HeroCTA.tsx index b5ffbc6c4..5ec31dfa0 100644 --- a/website/src/components/home/HeroCTA.tsx +++ b/website/src/components/home/HeroCTA.tsx @@ -8,6 +8,7 @@ import { spacing, typography, } from "@policyengine/design-system/tokens"; +import { trackEnterCalculatorClicked } from "@/lib/posthog-events"; const ctaVariant = { hidden: { opacity: 0, y: 20 }, @@ -24,6 +25,8 @@ const ctaVariant = { }; export default function HeroCTA({ countryId }: { countryId: string }) { + const destinationUrl = `${CALCULATOR_URL}/${countryId}/reports`; + return ( + trackEnterCalculatorClicked({ + country_id: countryId, + destination_url: destinationUrl, + }) + } whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.98 }} style={{ diff --git a/website/src/lib/posthog-events.ts b/website/src/lib/posthog-events.ts new file mode 100644 index 000000000..f96abe3b8 --- /dev/null +++ b/website/src/lib/posthog-events.ts @@ -0,0 +1,74 @@ +"use client"; + +import posthog from "posthog-js"; + +type WebsiteEventProperties = Record; + +function getClient() { + return posthog; +} + +export function captureWebsiteEvent( + eventName: string, + properties: WebsiteEventProperties = {}, +) { + getClient().capture(eventName, { + surface: "website", + ...properties, + }); +} + +export function captureWebsiteException( + error: unknown, + properties: WebsiteEventProperties = {}, +) { + const normalizedError = + error instanceof Error + ? error + : new Error(typeof error === "string" ? error : "Unknown website error"); + + getClient().captureException(normalizedError, { + surface: "website", + ...properties, + }); +} + +export function trackEnterCalculatorClicked( + properties: WebsiteEventProperties = {}, +) { + captureWebsiteEvent("enter_calculator_clicked", properties); +} + +export function trackNewsletterSignupStarted( + properties: WebsiteEventProperties = {}, +) { + captureWebsiteEvent("newsletter_signup_started", properties); +} + +export function trackNewsletterSignupSucceeded( + properties: WebsiteEventProperties = {}, +) { + captureWebsiteEvent("newsletter_signup_succeeded", properties); +} + +export function trackNewsletterSignupFailed( + properties: WebsiteEventProperties = {}, +) { + captureWebsiteEvent("newsletter_signup_failed", properties); +} + +export function trackResearchArticleViewed( + properties: WebsiteEventProperties = {}, +) { + captureWebsiteEvent("research_article_viewed", properties); +} + +export function trackResearchFiltersChanged( + properties: WebsiteEventProperties = {}, +) { + captureWebsiteEvent("research_filters_changed", properties); +} + +export function trackCountrySwitched(properties: WebsiteEventProperties = {}) { + captureWebsiteEvent("country_switched", properties); +} diff --git a/website/src/lib/posthog-server.ts b/website/src/lib/posthog-server.ts new file mode 100644 index 000000000..aa7a053da --- /dev/null +++ b/website/src/lib/posthog-server.ts @@ -0,0 +1,34 @@ +import { PostHog } from "posthog-node"; + +let posthogInstance: PostHog | null = null; + +function getPostHogToken() { + return ( + process.env.POSTHOG_PROJECT_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_TOKEN ?? + process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN + ); +} + +function getPostHogHost() { + return process.env.POSTHOG_HOST ?? process.env.NEXT_PUBLIC_POSTHOG_HOST; +} + +export function getPostHogServer() { + const token = getPostHogToken(); + const host = getPostHogHost(); + + if (!token || !host) { + return null; + } + + if (!posthogInstance) { + posthogInstance = new PostHog(token, { + host, + flushAt: 1, + flushInterval: 0, + }); + } + + return posthogInstance; +} diff --git a/website/src/lib/posthogProxy.ts b/website/src/lib/posthogProxy.ts new file mode 100644 index 000000000..40c45b396 --- /dev/null +++ b/website/src/lib/posthogProxy.ts @@ -0,0 +1,85 @@ +const POSTHOG_PROXY_PATH = "/_euclid"; + +type PostHogProxyConfig = { + apiHost: string; + apiDestination: string; + assetDestination: string; + uiHost: string; +}; + +const CLOUD_REGION_HOSTS: Record< + string, + { + assetDestination: string; + uiHost: string; + } +> = { + "us.i.posthog.com": { + uiHost: "https://us.posthog.com", + assetDestination: "https://us-assets.i.posthog.com", + }, + "eu.i.posthog.com": { + uiHost: "https://eu.posthog.com", + assetDestination: "https://eu-assets.i.posthog.com", + }, +}; + +function normalizeHost(host: string | null | undefined): URL | null { + if (!host) { + return null; + } + + try { + return new URL(host); + } catch { + return null; + } +} + +export function getPostHogProxyConfig( + host: string | null | undefined, +): PostHogProxyConfig | null { + const normalizedHost = normalizeHost(host); + + if (!normalizedHost) { + return null; + } + + const cloudHost = CLOUD_REGION_HOSTS[normalizedHost.hostname]; + + if (!cloudHost) { + return null; + } + + return { + apiHost: POSTHOG_PROXY_PATH, + apiDestination: normalizedHost.origin, + assetDestination: cloudHost.assetDestination, + uiHost: cloudHost.uiHost, + }; +} + +export function getPostHogProxyRewrites( + host: string | null | undefined, +): Array<{ destination: string; source: string }> { + const proxyConfig = getPostHogProxyConfig(host); + + if (!proxyConfig) { + return []; + } + + return [ + { + source: `${POSTHOG_PROXY_PATH}/static/:path*`, + destination: `${proxyConfig.assetDestination}/static/:path*`, + }, + { + source: `${POSTHOG_PROXY_PATH}/array/:path*`, + destination: `${proxyConfig.assetDestination}/array/:path*`, + }, + { + source: `${POSTHOG_PROXY_PATH}/:path*`, + destination: `${proxyConfig.apiDestination}/:path*`, + }, + ]; +}