Add plausible

This commit is contained in:
Andras Schmelczer 2026-02-22 23:14:42 +00:00
parent 48f2c97487
commit 4857800fca
14 changed files with 118 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
import VerificationBanner from './components/ui/VerificationBanner';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
@ -91,6 +92,10 @@ export default function App() {
// Restore from history state (e.g. popstate)
if (window.history.state?.page) return window.history.state.page;
// Unknown path — track as 404
if (window.location.pathname !== '/') {
trackEvent('404', { path: window.location.pathname });
}
return 'home';
});
@ -122,6 +127,7 @@ export default function App() {
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
trackEvent('Purchase');
setShowLicenseSuccess(true);
refreshAuth();
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import ScrollStory from './ScrollStory';
@ -6,6 +6,7 @@ import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { trackEvent } from '../../lib/analytics';
import type { FeatureMeta } from '../../types';
export default function HomePage({
@ -30,6 +31,35 @@ export default function HomePage({
const whyRef = useFadeInRef();
const ctaRef = useFadeInRef();
// Scroll depth tracking
const scrolledSections = useRef(new Set<string>());
useEffect(() => {
const ids = ['how-it-works', 'demo'];
const observers: IntersectionObserver[] = [];
ids.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !scrolledSections.current.has(id)) {
scrolledSections.current.add(id);
trackEvent('Scroll Depth', { section: id });
}
},
{ threshold: 0.1 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
// 30s time-on-page event
useEffect(() => {
const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000);
return () => clearTimeout(timer);
}, []);
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
@ -54,13 +84,17 @@ export default function HomePage({
</p>
<div className="flex items-center gap-4 mb-10">
<button
onClick={onOpenDashboard}
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
>
Explore the map
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('comparison');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
@ -106,6 +140,7 @@ export default function HomePage({
<div className="flex-1" />
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_it_in_action' });
const target = document.getElementById('demo');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
@ -245,7 +280,10 @@ export default function HomePage({
Don&apos;t leave it to chance.
</p>
<button
onClick={onOpenDashboard}
onClick={() => {
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
onOpenDashboard();
}}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Explore the map

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import MapComponent from '../map/Map';
import { trackEvent } from '../../lib/analytics';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { zoomToResolution } from '../../lib/map-utils';
@ -147,6 +148,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const [hexData, setHexData] = useState<HexagonData[]>([]);
const [loading, setLoading] = useState(true);
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
const trackedSteps = useRef(new Set<number>());
const abortRef = useRef<AbortController>();
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const sectionRef = useRef<HTMLElement>(null);
@ -182,7 +184,13 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setStage(i);
if (entry.isIntersecting) {
setStage(i);
if (!trackedSteps.current.has(i)) {
trackedSteps.current.add(i);
trackEvent('Scroll Story Step', { step: String(i) });
}
}
},
{ rootMargin: '-35% 0px -35% 0px', threshold: 0 }
);

View file

@ -27,6 +27,7 @@ import {
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
@ -261,6 +262,7 @@ export default function MapPage({
link.download = 'perfect-postcode-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
trackEvent('Export');
})
.catch((err) => logNonAbortError('Export failed', err))
.finally(() => setExporting(false));
@ -270,6 +272,10 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]

View file

@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -31,19 +32,23 @@ export default function POIPane({
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
const wasSelected = newSet.has(category);
if (wasSelected) {
newSet.delete(category);
} else {
newSet.add(category);
}
trackEvent('POI Toggle', { category, selected: String(!wasSelected) });
onCategoriesChange(newSet);
};
const selectAll = () => {
trackEvent('POI Select All');
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
trackEvent('POI Select None');
onCategoriesChange(new Set());
};

View file

@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { AuthUser } from '../../hooks/useAuth';
import { useLicense } from '../../hooks/useLicense';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const FEATURES = [
@ -58,6 +59,10 @@ export default function PricingPage({
if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0);
}, []);
useEffect(() => {
trackEvent('Pricing View');
}, []);
useEffect(() => {
fetch(apiUrl('pricing'))
.then((res) => {

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
type View = 'login' | 'register' | 'forgot';
@ -30,6 +31,10 @@ export default function AuthModal({
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
useEffect(() => {
trackEvent('Auth Modal Open', { tab: initialTab });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const switchView = useCallback(
(newView: View) => {
setView(newView);

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { trackEvent } from '../lib/analytics';
export interface AuthUser {
id: string;
@ -52,6 +53,7 @@ export function useAuth() {
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
setError(msg);
@ -73,6 +75,7 @@ export function useAuth() {
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Registration failed';
setError(msg);
@ -88,6 +91,7 @@ export function useAuth() {
try {
const result = await pb.collection('users').authWithOAuth2({ provider });
setUser(recordToUser(result.record));
trackEvent('Login', { method: provider });
} catch (err) {
const msg = err instanceof Error ? err.message : 'OAuth login failed';
setError(msg);
@ -98,6 +102,7 @@ export function useAuth() {
}, []);
const logout = useCallback(() => {
trackEvent('Logout');
pb.authStore.clear();
setUser(null);
}, []);

View file

@ -1,5 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -29,6 +30,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
trackEvent('Filter Add', { feature: name });
if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
} else if (meta.type === 'numeric' && meta.histogram) {
@ -45,6 +47,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleRemoveFilter = useCallback((name: string) => {
trackEvent('Filter Remove', { feature: name });
setFilters((prev) => {
const next = { ...prev };
delete next[name];
@ -84,6 +87,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleTogglePin = useCallback((name: string) => {
trackEvent('Filter Pin', { feature: name });
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
FeatureFilters,
@ -107,6 +108,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setSelectedPostcodeGeometry(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
trackEvent('Hexagon Click', { type });
setSelectedHexagon({ id, type, resolution });
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
setProperties([]);
@ -138,6 +140,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
trackEvent('View Properties');
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
@ -167,6 +170,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
trackEvent('Postcode Search');
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setProperties([]);

View file

@ -1,11 +1,13 @@
import { useState, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../lib/api';
import { trackEvent } from '../lib/analytics';
export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const startCheckout = useCallback(async (referralCode?: string) => {
trackEvent('Checkout Start', { has_referral: String(!!referralCode) });
setCheckingOut(true);
setError(null);
try {
@ -22,6 +24,7 @@ export function useLicense() {
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
trackEvent('Checkout Redirect');
window.location.href = data.url;
}
} catch (err) {

View file

@ -7,5 +7,6 @@ plausibleInit({
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
outboundLinks: true,
hashBasedRouting: true,
});

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics';
export interface SavedSearch {
id: string;
@ -66,6 +67,7 @@ export function useSavedSearches(userId: string | null) {
formData.append('screenshot', screenshotBlob, 'screenshot.png');
await pb.collection('saved_searches').create(formData);
trackEvent('Search Save');
await fetchSearches();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save search';
@ -82,6 +84,7 @@ export function useSavedSearches(userId: string | null) {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
trackEvent('Search Delete');
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');

View file

@ -0,0 +1,19 @@
import { track } from '@plausible-analytics/tracker';
export function trackEvent(name: string, props?: Record<string, string | number | boolean>) {
const stringProps: Record<string, string> | undefined = props
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
: undefined;
track(name, { props: stringProps });
}
export function trackRevenue(
name: string,
amountPence: number,
props?: Record<string, string | number | boolean>
) {
const stringProps = props
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
: undefined;
track(name, { props: stringProps, revenue: { amount: amountPence / 100, currency: 'GBP' } });
}