From 5a52f0134f1af8debf843e5474c12cf6e72a2970 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 20 May 2026 11:44:10 +1000 Subject: [PATCH 1/4] feat: Posthog analytics integration --- frontend/.env.example | 4 + frontend/package-lock.json | 423 +++++++++++++++++- frontend/package.json | 1 + frontend/src/app/layout.tsx | 45 +- .../components/analytics/posthog-pageview.tsx | 24 + .../components/analytics/posthog-provider.tsx | 24 + 6 files changed, 496 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/analytics/posthog-pageview.tsx create mode 100644 frontend/src/components/analytics/posthog-provider.tsx diff --git a/frontend/.env.example b/frontend/.env.example index 6403b41..d685be7 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -9,3 +9,7 @@ NEXTAUTH_URL=http://localhost:3000 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# PostHog +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bf031eb..5ac072a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", + "posthog-js": "^1.374.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -1682,6 +1683,252 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -1701,6 +1948,84 @@ "node": ">=14" } }, + "node_modules/@posthog/core": { + "version": "1.29.5", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.29.5.tgz", + "integrity": "sha512-Jm5AE95EwBRqO6J8+skDufyf5rnEcmOvjYArCKCOzD4mWdH1xGpfcRXj5TEyZII3mD04Kr7pw9aP2ZbAHQGu2A==", + "license": "MIT", + "dependencies": { + "@posthog/types": "1.374.2" + } + }, + "node_modules/@posthog/types": { + "version": "1.374.2", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.374.2.tgz", + "integrity": "sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1796,7 +2121,6 @@ "version": "22.13.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz", "integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -2875,6 +3199,17 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3130,9 +3465,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", - "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3977,6 +4312,12 @@ "reusify": "^1.0.4" } }, + "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/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5308,6 +5649,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -6360,6 +6707,37 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/posthog-js": { + "version": "1.374.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.374.2.tgz", + "integrity": "sha512-6z1xGlVocd3NmSZlJNFfpedLIHLcejuuQPxvrpHDvtyVI9tN1NPqbM7T7coXw2It6gdZ/nAgDuZkNxfIut+Spw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@posthog/core": "1.29.5", + "@posthog/types": "1.374.2", + "core-js": "^3.38.1", + "dompurify": "^3.3.2", + "fflate": "^0.4.8", + "preact": "^10.28.2", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.1.0" + } + }, + "node_modules/posthog-js/node_modules/preact": { + "version": "10.29.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", + "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -6441,6 +6819,30 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", + "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -6460,6 +6862,12 @@ "node": ">=6" } }, + "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", @@ -7907,7 +8315,6 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -8066,6 +8473,12 @@ "node": ">=18" } }, + "node_modules/web-vitals": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", + "integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f827958..6349fbe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "next-auth": "^4.24.13", "pino": "^9.11.0", "pino-pretty": "^13.1.1", + "posthog-js": "^1.374.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 1fe69ee..b0de767 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -23,6 +23,8 @@ import { Notifications } from "@mantine/notifications"; import FirstVisitNotification from "@/components/ui/first-visit-notification"; import AuthSessionProvider from "@/components/auth/session-provider"; +import { PHProvider } from "@/components/analytics/posthog-provider"; +import { PostHogPageView } from "@/components/analytics/posthog-pageview"; export const metadata: Metadata = { title: { @@ -53,26 +55,29 @@ export default function RootLayout({ children }: PropsWithChildren) { - - - - -
- - -
- {children} - - - - - -
-
-
-
-
-
+ + + + + + +
+ + +
+ {children} + + + + + +
+
+
+
+
+
+
); diff --git a/frontend/src/components/analytics/posthog-pageview.tsx b/frontend/src/components/analytics/posthog-pageview.tsx new file mode 100644 index 0000000..6d8012d --- /dev/null +++ b/frontend/src/components/analytics/posthog-pageview.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { usePathname, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; +import { usePostHog } from "posthog-js/react"; + +export function PostHogPageView() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const posthog = usePostHog(); + + useEffect(() => { + if (pathname && posthog) { + let url = window.origin + pathname; + const search = searchParams.toString(); + if (search) { + url += `?${search}`; + } + posthog.capture("$pageview", { $current_url: url }); + } + }, [pathname, searchParams, posthog]); + + return null; +} diff --git a/frontend/src/components/analytics/posthog-provider.tsx b/frontend/src/components/analytics/posthog-provider.tsx new file mode 100644 index 0000000..5cb4fa9 --- /dev/null +++ b/frontend/src/components/analytics/posthog-provider.tsx @@ -0,0 +1,24 @@ +"use client"; + +import posthog from "posthog-js"; +import { PostHogProvider } from "posthog-js/react"; +import { useEffect } from "react"; + +export function PHProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + + if (!posthogKey) { + return; + } + + posthog.init(posthogKey, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + ui_host: "https://us.posthog.com", + capture_pageview: false, + capture_pageleave: true, + }); + }, []); + + return {children}; +} From e2fcacf73d18c6d834703ec6c679b74940c6fecb Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 24 May 2026 02:19:53 +1000 Subject: [PATCH 2/4] feat: Fix sign-up flow and update application statistics. - removed multiple modal popups when clicking apply - added a new sign in message for applications and statistics - Navbar now shoes features to incentivise signing up --- frontend/src/app/globals.css | 142 ++++++++++++++++++ .../applications/my-applications-client.tsx | 19 +-- .../components/auth/auth-required-panel.tsx | 119 +++++++++++++++ frontend/src/components/jobs/job-details.tsx | 33 +++- .../src/components/layout/nav-bar-mobile.tsx | 7 +- frontend/src/components/layout/nav-links.tsx | 20 +-- .../applications-statistics-client.tsx | 20 +-- 7 files changed, 305 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/auth/auth-required-panel.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index cf624ab..24cc6c6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -71,6 +71,148 @@ body { top: 72px; } +/* ========================================================================== + Signed-out product gates + ========================================================================== */ + +.auth-required-panel { + min-height: min(520px, calc(100svh - 160px)); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: + radial-gradient( + circle at 1px 1px, + rgba(255, 255, 255, 0.08) 1px, + transparent 0 + ) + 0 0 / 24px 24px, + linear-gradient(135deg, rgba(46, 46, 46, 0.96), rgba(31, 31, 31, 0.98)); + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(230px, 0.92fr); + gap: 24px; + align-items: center; + padding: clamp(24px, 5vw, 52px); + overflow: hidden; +} +.auth-required-copy { + max-width: 620px; +} +.auth-required-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--accent); + font-size: 12px; + font-weight: 900; + line-height: 1; + text-transform: uppercase; +} +.auth-required-lock { + width: 28px; + height: 28px; + border: 1px solid rgba(255, 226, 47, 0.32); + border-radius: 8px; + background: rgba(255, 226, 47, 0.1); + display: inline-flex; + align-items: center; + justify-content: center; +} +.auth-required-panel h1 { + max-width: 680px; + margin: 18px 0 0; + color: white; + font-size: clamp(30px, 4.5vw, 50px); + font-weight: 900; + line-height: 0.98; +} +.auth-required-copy > p { + max-width: 590px; + margin: 18px 0 0; + color: var(--muted-1); + font-size: 15px; + font-weight: 600; + line-height: 1.55; +} +.auth-required-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 26px; +} +.auth-required-primary, +.auth-required-secondary { + min-height: 40px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 15px; + font-size: 13px; + font-weight: 900; + line-height: 1; + transition: + transform 0.12s ease, + filter 0.12s ease, + border-color 0.12s ease; +} +.auth-required-primary { + background: var(--accent); + color: #1f1f1f; +} +.auth-required-secondary { + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.78); +} +.auth-required-primary:hover, +.auth-required-secondary:hover { + transform: translateY(-1px); + filter: brightness(1.04); +} +.auth-required-secondary:hover { + border-color: rgba(255, 255, 255, 0.24); +} +.auth-required-feature-grid { + display: grid; + gap: 10px; +} +.auth-required-feature { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 11px; + align-items: start; + padding: 14px; + backdrop-filter: blur(14px); +} +.auth-required-feature > span { + width: 34px; + height: 34px; + border-radius: 8px; + background: rgba(255, 226, 47, 0.1); + color: var(--accent); + display: inline-flex; + align-items: center; + justify-content: center; +} +.auth-required-feature h2 { + margin: 0; + color: white; + font-size: 13px; + font-weight: 900; + line-height: 1.15; +} +.auth-required-feature p { + margin: 5px 0 0; + color: var(--muted-2); + font-size: 12px; + font-weight: 600; + line-height: 1.4; +} + /* ========================================================================== Statistics ========================================================================== */ diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index 7fd6625..0e68a43 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -29,7 +29,6 @@ import { IconTrash, } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; -import Link from "next/link"; import { ApplicationStatus, DbApplication, @@ -63,6 +62,7 @@ import ApplicationDatePicker, { formatApplicationDateValue, } from "@/components/applications/application-date-picker"; import { rolePalette } from "@/lib/role-palette"; +import AuthRequiredPanel from "@/components/auth/auth-required-panel"; const SORT_STORAGE_KEY = "mp:apps:kanban-sort:v1"; const DENSITY_STORAGE_KEY = "mp:apps:kanban-density:v1"; @@ -637,22 +637,7 @@ export default function MyApplicationsClient({ } if (sessionStatus === "unauthenticated") { - return ( - - You're not signed in.{" "} - - Sign in - {" "} - to view and manage your applications across devices. - - ); + return ; } const segmentedStyles = { diff --git a/frontend/src/components/auth/auth-required-panel.tsx b/frontend/src/components/auth/auth-required-panel.tsx new file mode 100644 index 0000000..6386254 --- /dev/null +++ b/frontend/src/components/auth/auth-required-panel.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { + IconArrowRight, + IconChartBar, + IconClipboardList, + IconDevices, + IconLock, + IconTimelineEvent, +} from "@tabler/icons-react"; + +type AuthRequiredScreen = "applications" | "statistics"; + +const screenCopy = { + applications: { + eyebrow: "Applications", + title: "Sign in to manage your application tracker", + description: + "You can keep browsing jobs without an account. Sign in when you want full functionality, application tracking, saved statuses, and sync across devices.", + callbackUrl: "/my-applications", + features: [ + { + icon: IconClipboardList, + title: "Application board", + description: "Save roles and move them through your pipeline.", + }, + { + icon: IconDevices, + title: "Device sync", + description: "Keep your tracker consistent anywhere you sign in.", + }, + ], + }, + statistics: { + eyebrow: "Statistics", + title: "Sign in to unlock your application statistics", + description: + "Statistics are built from your tracked applications. Sign in for full functionality, tracking, pipeline movement, and progress across each recruitment cycle.", + callbackUrl: "/statistics", + features: [ + { + icon: IconChartBar, + title: "Pipeline insights", + description: "See how many roles reach each stage.", + }, + { + icon: IconTimelineEvent, + title: "Status history", + description: "Turn tracked updates into useful trends.", + }, + ], + }, +} satisfies Record< + AuthRequiredScreen, + { + eyebrow: string; + title: string; + description: string; + callbackUrl: string; + features: { + icon: typeof IconClipboardList; + title: string; + description: string; + }[]; + } +>; + +export default function AuthRequiredPanel({ + screen, +}: { + screen: AuthRequiredScreen; +}) { + const copy = screenCopy[screen]; + const signInHref = `/sign-in?callbackUrl=${encodeURIComponent(copy.callbackUrl)}`; + + return ( +
+
+
+ + + + {copy.eyebrow} +
+

{copy.title}

+

{copy.description}

+
+ + Sign in + + + + Browse jobs + +
+
+ +
+ {copy.features.map((feature) => { + const FeatureIcon = feature.icon; + + return ( +
+ + + +
+

{feature.title}

+

{feature.description}

+
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/jobs/job-details.tsx b/frontend/src/components/jobs/job-details.tsx index 076d353..d432820 100644 --- a/frontend/src/components/jobs/job-details.tsx +++ b/frontend/src/components/jobs/job-details.tsx @@ -22,13 +22,16 @@ import { sendGAEvent } from "@next/third-parties/google"; import Link from "next/link"; import { useSession } from "next-auth/react"; +const APPLY_SIGNIN_PROMPT_SESSION_KEY = "mp:apply-signin-prompt-shown:v1"; + export default function JobDetails() { const { selectedJob, isLoading } = useFilterContext(); const scrollRef = useRef(null); const [isCopied, setIsCopied] = useState(false); const timeoutRef = useRef(null); + const signinPromptShownRef = useRef(false); const [showSigninModal, setShowSigninModal] = useState(false); - const { data: session } = useSession(); + const { data: session, status: sessionStatus } = useSession(); // Scroll to top whenever a new job is selected useEffect(() => { @@ -49,6 +52,27 @@ export default function JobDetails() { return ; } + const shouldShowSigninPrompt = () => { + if (signinPromptShownRef.current) return false; + + try { + if ( + window.sessionStorage.getItem(APPLY_SIGNIN_PROMPT_SESSION_KEY) === + "true" + ) { + signinPromptShownRef.current = true; + return false; + } + + window.sessionStorage.setItem(APPLY_SIGNIN_PROMPT_SESSION_KEY, "true"); + } catch { + // If sessionStorage is unavailable, fall back to once per component mount. + } + + signinPromptShownRef.current = true; + return true; + }; + const handleApplyClick = () => { window.open(selectedJob.application_url, "_blank"); @@ -64,7 +88,7 @@ export default function JobDetails() { company: selectedJob.company?.name || "Unknown", }); - if (session?.user) { + if (sessionStatus === "authenticated" && session?.user) { addApplication(selectedJob.id, { jobId: selectedJob.id, title: selectedJob.title, @@ -74,7 +98,10 @@ export default function JobDetails() { }); } else { upsertLocalStartedApplication(selectedJob); - setShowSigninModal(true); + + if (sessionStatus === "unauthenticated" && shouldShowSigninPrompt()) { + setShowSigninModal(true); + } } }; diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index 4758aa0..1beeed0 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -16,11 +16,10 @@ export const NavBarMobile = () => { const menuItems = [ { href: "/", label: "Home" }, { href: "/jobs", label: "Jobs" }, + { href: "/my-applications", label: "Applications" }, + { href: "/statistics", label: "Statistics" }, ...(status === "authenticated" - ? [ - { href: "/my-applications", label: "Applications" }, - { href: "/statistics", label: "Statistics" }, - ] + ? [] : [{ href: "/sign-in", label: "Sign in" }]), ]; diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index d855397..da6e645 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -20,20 +20,12 @@ export default function NavLinks() { Jobs - - {status === "authenticated" && ( - <> - - Applications - - - Statistics - - - )} + + Applications + + + Statistics + {status === "authenticated" ? ( diff --git a/frontend/src/components/statistics/applications-statistics-client.tsx b/frontend/src/components/statistics/applications-statistics-client.tsx index 85f192b..b07b18a 100644 --- a/frontend/src/components/statistics/applications-statistics-client.tsx +++ b/frontend/src/components/statistics/applications-statistics-client.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import type { KeyboardEvent, Ref } from "react"; import { useSession } from "next-auth/react"; -import { Box, Select } from "@mantine/core"; +import { Select } from "@mantine/core"; import { IconDownload, IconExternalLink, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { @@ -15,6 +15,7 @@ import { UserStage, } from "@/types/application"; import macLogo from "@/assets/mac.svg"; +import AuthRequiredPanel from "@/components/auth/auth-required-panel"; import CompanyLogo from "@/components/jobs/company-logo"; import { rolePalette } from "@/lib/role-palette"; import { relativeDate } from "@/lib/utils"; @@ -1291,22 +1292,7 @@ export default function ApplicationsStatisticsClient({ } if (sessionStatus === "unauthenticated") { - return ( - - You're not signed in.{" "} - - Sign in - {" "} - to view your application statistics. - - ); + return ; } return ( From e8f85b4faef0d6de3516f70807e7c7ef863eded7 Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 24 May 2026 03:20:11 +1000 Subject: [PATCH 3/4] fix: syncing non-signed in and signed in now accounts for time - if local change time is more recent, update database otherwise keep database --- frontend/src/app/my-applications/actions.ts | 115 ++++++++++++++---- .../applications/my-applications-client.tsx | 9 +- 2 files changed, 100 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index a0eaf64..9a0a8a6 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -188,6 +188,12 @@ async function recordApplicationStatusEvent( } } +function parseLocalDate(value: string | undefined, fallback: Date) { + if (!value) return fallback; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? fallback : date; +} + export async function listRecruitmentCycles(): Promise { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); @@ -297,44 +303,107 @@ export async function syncLocalApplications(apps: LocalApplication[]) { const userId = requireUserId(session); const userObjectId = new ObjectId(userId); - if (!apps.length) return { ok: true, upserted: 0 }; - const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); - const collection = db.collection("applications"); + const collection = db.collection("applications"); - let upserted = 0; + if (!apps.length) { + const current = await collection + .find({ userId: userObjectId }) + .sort({ updatedAt: -1 }) + .toArray(); + + return { + ok: true, + upserted: 0, + inserted: 0, + updated: 0, + skipped: 0, + applications: current.map((app) => serializeApplication(app)), + }; + } + + let inserted = 0; + let updated = 0; + let skipped = 0; for (const app of apps) { - const result = await collection.updateOne( - { userId: userObjectId, jobId: app.jobId }, + const now = new Date(); + const localStartedAt = parseLocalDate(app.startedAt, now); + const localUpdatedAt = parseLocalDate(app.updatedAt, localStartedAt); + const nextCycleId = app.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID; + const existing = await collection.findOne({ + userId: userObjectId, + jobId: app.jobId, + }); + + if (!existing) { + await collection.insertOne({ + _id: new ObjectId(), + userId: userObjectId, + jobId: app.jobId, + status: app.status, + startedAt: localStartedAt, + updatedAt: localUpdatedAt, + jobSnapshot: app.jobSnapshot, + cycleId: nextCycleId, + ...(app.starred !== undefined ? { starred: app.starred } : {}), + }); + + await recordApplicationStatusEvent(db, userObjectId, { + jobId: app.jobId, + fromStatus: null, + toStatus: app.status, + cycleId: nextCycleId, + source: "local_sync", + }); + + inserted += 1; + continue; + } + + if (localUpdatedAt.getTime() <= existing.updatedAt.getTime()) { + skipped += 1; + continue; + } + + await collection.updateOne( + { _id: existing._id }, { - $setOnInsert: { - startedAt: new Date(app.startedAt), - }, $set: { - updatedAt: new Date(app.updatedAt), + updatedAt: localUpdatedAt, status: app.status, jobSnapshot: app.jobSnapshot, - cycleId: app.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, + cycleId: nextCycleId, ...(app.starred !== undefined ? { starred: app.starred } : {}), }, }, - { upsert: true }, ); - if (result.upsertedCount > 0) { - await recordApplicationStatusEvent(db, userObjectId, { - jobId: app.jobId, - fromStatus: null, - toStatus: app.status, - cycleId: app.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, - source: "local_sync", - }); - } - upserted += 1; + + await recordApplicationStatusEvent(db, userObjectId, { + jobId: app.jobId, + fromStatus: existing.status, + toStatus: app.status, + cycleId: nextCycleId, + source: "local_sync", + }); + + updated += 1; } - return { ok: true, upserted }; + const current = await collection + .find({ userId: userObjectId }) + .sort({ updatedAt: -1 }) + .toArray(); + + return { + ok: true, + upserted: inserted + updated, + inserted, + updated, + skipped, + applications: current.map((app) => serializeApplication(app)), + }; } export async function listApplications(): Promise { diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index 0e68a43..8ca35af 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -241,8 +241,15 @@ export default function MyApplicationsClient({ try { const res = await syncLocalApplications(local); clearLocalApplications(); + setApps(res.applications); + + const synced = res.inserted + res.updated; + const syncedLabel = `${synced} application${synced === 1 ? "" : "s"}`; + const skippedLabel = `${res.skipped} already newer in your account`; setSyncMessage( - `Synced ${res.upserted} application${res.upserted === 1 ? "" : "s"} from this device.`, + synced > 0 + ? `Synced ${syncedLabel} from this device${res.skipped > 0 ? `; ${skippedLabel}.` : "."}` + : `Your account already had the latest version of ${res.skipped} local application${res.skipped === 1 ? "" : "s"}.`, ); } catch { setSyncMessage("Couldn't sync local applications yet. Try refreshing."); From 59ab7b57e4f55f48d964286badc41d9f38005973 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 25 May 2026 11:08:14 +1000 Subject: [PATCH 4/4] feat: privacy policy page --- frontend/src/app/layout.tsx | 9 + frontend/src/app/privacy/page.tsx | 287 ++++++++++++++++++++++++++++++ frontend/src/app/sign-in/page.tsx | 7 + frontend/src/app/sign-up/page.tsx | 8 + 4 files changed, 311 insertions(+) create mode 100644 frontend/src/app/privacy/page.tsx diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b0de767..4b2e309 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -7,6 +7,7 @@ import "@mantine/notifications/styles.css"; import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { GoogleAnalytics } from "@next/third-parties/google"; +import Link from "next/link"; import NavBar from "@/components/layout/nav-bar"; import { MantineProvider } from "@mantine/core"; @@ -68,6 +69,14 @@ export default function RootLayout({ children }: PropsWithChildren) { {children} +
+ + Privacy + +
diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx new file mode 100644 index 0000000..8c988ca --- /dev/null +++ b/frontend/src/app/privacy/page.tsx @@ -0,0 +1,287 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Privacy Policy", + description: + "How MAC Jobs Board collects, uses, stores, and shares personal information.", +}; + +const lastUpdated = "May 25, 2026"; + +const providerLinks = [ + { + name: "PostHog", + href: "https://posthog.com/privacy", + }, + { + name: "Google", + href: "https://policies.google.com/privacy", + }, + { + name: "Vercel", + href: "https://vercel.com/legal/privacy-policy", + }, + { + name: "Notion", + href: "https://www.notion.com/privacy", + }, +]; + +export default function PrivacyPolicyPage() { + return ( +
+
+

+ Last updated {lastUpdated} +

+

+ Privacy Policy +

+

+ This Privacy Policy explains how MAC Jobs Board collects, uses, + stores, and shares information when you use our job board, create an + account, save applications, send feedback, or interact with our + analytics and performance tools. +

+
+ +
+
+

Who We Are

+

+ MAC Jobs Board is a job discovery and application tracking service. + In this policy, "we", "us", and "our" + refer to the people operating MAC Jobs Board. "You" refers + to people who visit or use the site. +

+
+ +
+

+ Information We Collect +

+

+ We collect information you provide directly, information created + through your use of the service, and limited technical information + collected automatically. +

+
    +
  • + Account information, including your email address, optional name, + password hash for email/password accounts, and Google account + profile details if you sign in with Google. +
  • +
  • + Application tracking information, including saved jobs, custom job + entries, companies, roles, application statuses, recruitment + cycles, starred jobs, notes, and status history. +
  • +
  • + Feedback information, including the message you submit and any + email address you choose to include. +
  • +
  • + Usage and analytics information, including pages viewed, page + leave events, browser and device details, approximate location + derived from network information, referring pages, cookies or + local storage identifiers, and similar technical data. +
  • +
  • + Local browser data, including preferences and temporary + application tracking data stored in your browser so the site can + keep working smoothly between visits. +
  • +
+
+ +
+

+ How We Use Information +

+
    +
  • + To create accounts, authenticate users, and keep you signed in. +
  • +
  • + To save and sync your application tracker, notes, statuses, and + statistics. +
  • +
  • + To provide job search, filtering, job details, and related product + features. +
  • +
  • + To respond to feedback, investigate bugs, improve performance, and + decide what to build next. +
  • +
  • + To detect abuse, protect accounts, maintain security, and comply + with legal obligations. +
  • +
  • + To send service-related messages, such as account, security, or + important product notices. We will only send marketing messages + where permitted by law. +
  • +
+
+ +
+

+ Analytics, Cookies, and Similar Technologies +

+

+ We use PostHog, Google Analytics, Vercel Analytics, and Vercel Speed + Insights to understand how the site is used and how it performs. + These tools may use cookies, local storage, pixels, or similar + technologies to collect usage and technical information. You can + limit cookies through your browser settings, and some browsers or + extensions may block analytics requests. +

+

+ We do not intentionally send sensitive application notes or + passwords to analytics providers. You should avoid putting sensitive + personal information into fields where it is not needed. +

+
+ +
+

+ When We Share Information +

+

+ We share information with service providers that help us run the + site. These providers are allowed to process information only for + the purposes described in this policy and their own applicable + terms. +

+
    +
  • + Database and hosting providers, including MongoDB and Azure. +
  • +
  • + Authentication providers, including NextAuth and Google sign-in + where you choose to use Google. +
  • +
  • + Analytics and performance providers, including PostHog, Google + Analytics, Vercel Analytics, and Vercel Speed Insights. +
  • +
  • + Feedback tooling, including Notion, when you submit feedback + through the site. +
  • +
  • + Professional, legal, security, or compliance advisers if needed to + protect users, the service, or our legal rights. +
  • +
+

+ We do not sell personal information for money. We also do not + knowingly share personal information for cross-context behavioral + advertising. +

+
+ +
+

Data Retention

+

+ We keep account and application tracking information for as long as + your account is active or as long as needed to provide the service. + If you delete application entries, we stop using those entries in + the active product. Feedback, analytics, logs, and backup copies may + be kept for a limited period where needed for support, security, + debugging, legal compliance, or ordinary backup processes. +

+
+ +
+

+ Your Choices and Rights +

+

+ Depending on where you live, you may have rights to access, correct, + delete, export, restrict, or object to certain uses of your personal + information. You may also have the right to withdraw consent where + processing is based on consent. +

+

+ To make a privacy request, use the feedback button in the app and + include the email address connected to your account so we can verify + and respond to the request. If your request is sensitive, do not + post it in a public GitHub issue. +

+
+ +
+

+ International Processing +

+

+ We and our service providers may process and store information in + countries other than your own, including Australia, the United + States, and other locations where our providers operate. Where + required, we rely on appropriate safeguards for international data + transfers. +

+
+ +
+

Security

+

+ We use reasonable technical and organisational measures designed to + protect personal information, including password hashing for + email/password accounts. No online service can guarantee perfect + security, so you should use a strong password and keep your account + credentials private. +

+
+ +
+

Children

+

+ MAC Jobs Board is not directed to children under 13. If you believe + a child has provided personal information without appropriate + consent, contact us through the feedback button and we will review + the request. +

+
+ +
+

+ Changes to This Policy +

+

+ We may update this policy from time to time. If we make material + changes, we will update the date above and, where appropriate, + provide additional notice in the site. +

+
+ +
+

+ Third-Party Privacy Policies +

+

+ These links may help you understand how some of our service + providers handle information: +

+ +
+
+
+ ); +} diff --git a/frontend/src/app/sign-in/page.tsx b/frontend/src/app/sign-in/page.tsx index 7ffabb3..4dd6537 100644 --- a/frontend/src/app/sign-in/page.tsx +++ b/frontend/src/app/sign-in/page.tsx @@ -74,6 +74,13 @@ export default function SignInPage() { +

+ We handle account and usage data as described in our{" "} + + Privacy Policy + + . +

+

+ By creating an account, you acknowledge that we process your + information as described in our{" "} + + Privacy Policy + + . +