From 8287a38b4303321b0af42f4a0d8bf5ccae1a8654 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 11:06:41 +0100 Subject: [PATCH 001/428] chore: add 429 errors --- docs/polish-audit.md | 80 ++++++++++++++++++++++++++++++++++++++++++++ lib/api/client.ts | 9 +++++ lib/swr/dashboard.ts | 11 +++++- package-lock.json | 8 ++--- package.json | 2 +- 5 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 docs/polish-audit.md diff --git a/docs/polish-audit.md b/docs/polish-audit.md new file mode 100644 index 0000000..9b6abcb --- /dev/null +++ b/docs/polish-audit.md @@ -0,0 +1,80 @@ +# Pulse Polish Audit + +**Date:** 2026-03-16 +**Version:** 0.15.0-alpha + +## Current State + +Pulse is a mature product — 55+ routes, 156 API handlers, 50 migrations, 8 background workers, 3 third-party integrations. The codebase is clean with zero TODO/FIXME comments. Recent work has focused on UX refinements: empty state CTAs, animated number transitions, inline bar charts, and mobile responsiveness fixes. + +## Polish Opportunities + +### High Impact + +#### ~~1. Custom 404 Page~~ ✅ Already Done +- Branded 404 page exists at `app/not-found.tsx` with "Go back home" and "View FAQ" CTAs + +#### 2. Rate Limit (429) UX Feedback +- **Issue:** When rate limited, API requests fail silently with no user feedback +- **Effort:** Low +- **Action:** Catch 429 responses in the API client and show a toast with retry timing (`Retry-After` header) + +### Medium Impact + +#### 3. Export Progress Indicator +- **Issue:** PDF/Excel exports have no progress bar and can appear frozen on large datasets +- **Effort:** Low +- **Action:** Add a determinate or indeterminate progress bar inside `ExportModal.tsx` + +#### 4. Filter Application Feedback +- **Issue:** Applying filters has no loading/transition indicator +- **Effort:** Low +- **Action:** Show a subtle loading state on the dashboard when filters change and data is refetching + +#### 5. Inline Form Validation +- **Issue:** All validation errors go to toasts only; no inline field-level error messages +- **Effort:** Medium +- **Action:** Add inline error messages below form fields (funnel creation, goal creation, site settings) + +#### 6. Accessibility: Modal Focus Trapping +- **Issue:** Modals don't trap focus, breaking keyboard-only navigation +- **Effort:** Medium +- **Action:** Implement focus trapping in modal components (VerificationModal, ExportModal, settings modals) + +#### 7. Accessibility: ARIA Live Regions +- **Issue:** Real-time visitor count updates aren't announced to screen readers +- **Effort:** Low +- **Action:** Add `aria-live="polite"` to `RealtimeVisitors.tsx` and other auto-updating elements + +#### 8. Table Pagination Loading +- **Issue:** No loading indicator when paginating through table data +- **Effort:** Low +- **Action:** Show a loading spinner or skeleton overlay when fetching the next page of results + +### Low Impact + +#### 9. Remove Unused `axios` Dependency +- **Issue:** `apiRequest` is the actual HTTP client; `axios` is dead weight in the bundle +- **Effort:** Trivial +- **Action:** `npm uninstall axios` and verify no imports reference it + +#### 10. PWA Service Worker Caching +- **Issue:** `@ducanh2912/next-pwa` is configured but no offline caching strategy exists +- **Effort:** Medium +- **Action:** Define a caching strategy for static assets and API responses, or remove the PWA dependency + +#### 11. Image Lazy Loading +- **Issue:** Table/list images (favicons, flags) don't use `loading="lazy"` +- **Effort:** Low +- **Action:** Add `loading="lazy"` to images in referrer lists, country tables, and similar components + +#### 12. OpenAPI Specification +- **Issue:** Backend has no Swagger/OpenAPI doc; README serves as the only API documentation +- **Effort:** High +- **Action:** Generate an OpenAPI spec from Go handler annotations or write one manually + +## Additional Notes + +- **No i18n** — English only. Worth planning if expanding to international markets. +- **Test coverage** — 16 backend test files cover core logic well, but GSC/BunnyCDN sync and report delivery e2e are gaps. +- **Chart.tsx is 35KB** — A candidate for splitting into sub-components eventually. diff --git a/lib/api/client.ts b/lib/api/client.ts index 5014fc5..4d348e5 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -335,6 +335,15 @@ async function apiRequest( } const errorBody = await response.json().catch(() => ({})) + + // * Capture Retry-After header on 429 so callers can show precise timing + if (response.status === 429) { + const retryAfter = response.headers.get('Retry-After') + if (retryAfter) { + errorBody.retryAfter = parseInt(retryAfter, 10) + } + } + const message = authMessageFromStatus(response.status) throw new ApiError(message, response.status, errorBody) } diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts index 9dc62e1..8b15a85 100644 --- a/lib/swr/dashboard.ts +++ b/lib/swr/dashboard.ts @@ -2,6 +2,7 @@ // * Implements stale-while-revalidate pattern for efficient data updates import useSWR from 'swr' +import { toast } from '@ciphera-net/ui' import { getDashboard, getDashboardOverview, @@ -105,7 +106,15 @@ const dashboardSWRConfig = { errorRetryInterval: 5000, // * Don't retry on 429 (rate limit) or 401/403 (auth) — retrying makes it worse onErrorRetry: (error: any, _key: string, _config: any, revalidate: any, { retryCount }: { retryCount: number }) => { - if (error?.status === 429 || error?.status === 401 || error?.status === 403) return + if (error?.status === 429) { + const retryAfter = error?.data?.retryAfter + const message = retryAfter + ? `Too many requests. Please try again in ${retryAfter} seconds.` + : 'Too many requests. Please wait a moment and try again.' + toast.error(message, { id: 'rate-limit' }) + return + } + if (error?.status === 401 || error?.status === 403) return if (retryCount >= 3) return setTimeout(() => revalidate({ retryCount }), 5000 * Math.pow(2, retryCount)) }, diff --git a/package-lock.json b/package-lock.json index 5c13c9c..5148ded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.15.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.6", + "@ciphera-net/ui": "^0.2.7", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1667,9 +1667,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.6", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.6/85b0deed2ec86461502209b098f64e028b49e63e", - "integrity": "sha512-mNlK4FNwWAYiScMcyTP7Y5EEZjSUf8H63EdQUlcTEWoFo4km5ZPrlJcfPsbUsN65YB9OtT+iAiu/XRG4dI0/Gg==", + "version": "0.2.7", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.7/f5f170676cdd1bf53c091a0baa98c2d55a7c999f", + "integrity": "sha512-yvag9cYfX6c8aZ3bKI+i3l9ALJBXg7XL6soIjd65F7NyZN+1mEo1Fb+ARfWgjdNa5HjfexAnEOOVpjwMNPFCfg==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index ea1f781..cf8b313 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.6", + "@ciphera-net/ui": "^0.2.7", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", -- 2.49.1 From ed865c9a6fa5a23f15897c6164010420e16132b1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 11:27:31 +0100 Subject: [PATCH 002/428] chore: remove unused axios dependency All HTTP calls use the native fetch-based apiRequest client. --- package-lock.json | 81 ----------------------------------------------- package.json | 1 - 2 files changed, 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5148ded..b0a4afe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@tanstack/react-virtual": "^3.13.21", - "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", @@ -7066,12 +7065,6 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -7143,17 +7136,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -7641,18 +7623,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -8152,15 +8122,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -9249,26 +9210,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9284,22 +9225,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/frac": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", @@ -12477,12 +12402,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index cf8b313..cda1d08 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@tanstack/react-virtual": "^3.13.21", - "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", -- 2.49.1 From e4291c44a8ddf933e2c60aac22b3f24a30db416b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 11:32:17 +0100 Subject: [PATCH 003/428] feat: add progress bar to export modal Show step-by-step progress during PDF/XLSX exports with percentage, stage label, and animated orange bar. Yields to UI thread between stages so the browser can repaint. --- components/dashboard/ExportModal.tsx | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index 39566d1..87f3fc1 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -51,6 +51,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`) const [includeHeader, setIncludeHeader] = useState(true) const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState({ step: 0, total: 1, label: '' }) const [selectedFields, setSelectedFields] = useState>({ date: true, pageviews: true, @@ -63,8 +64,15 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to setSelectedFields((prev) => ({ ...prev, [field]: checked })) } + // Yield to the UI thread so the browser can paint progress updates + const updateProgress = useCallback(async (step: number, total: number, label: string) => { + setExportProgress({ step, total, label }) + await new Promise(resolve => setTimeout(resolve, 0)) + }, []) + const handleExport = () => { setIsExporting(true) + setExportProgress({ step: 0, total: 1, label: 'Preparing...' }) // Let the browser paint the loading state before starting heavy work requestAnimationFrame(() => { setTimeout(async () => { @@ -100,6 +108,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to mimeType = 'text/csv;charset=utf-8;' extension = 'csv' } else if (format === 'xlsx') { + await updateProgress(1, 2, 'Building spreadsheet...') const ws = XLSX.utils.json_to_sheet(exportData) const wb = XLSX.utils.book_new() XLSX.utils.book_append_sheet(wb, ws, 'Data') @@ -128,9 +137,12 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to onClose() return } else if (format === 'pdf') { + const totalSteps = 3 + (topPages?.length ? 1 : 0) + (topReferrers?.length ? 1 : 0) + (campaigns?.length ? 1 : 0) + let currentStep = 0 const doc = new jsPDF() // Header Section + await updateProgress(++currentStep, totalSteps, 'Building header...') try { // Logo const logoData = await loadImage('/pulse_icon_no_margins.png') @@ -195,6 +207,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to startY = 65 // Move table down } + await updateProgress(++currentStep, totalSteps, 'Generating data table...') // Check if data is hourly (same date for multiple rows) const isHourly = data.length > 1 && data[0].date.split('T')[0] === data[1].date.split('T')[0] @@ -258,6 +271,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to // Top Pages Table if (topPages && topPages.length > 0) { + await updateProgress(++currentStep, totalSteps, 'Adding top pages...') // Check if we need a new page if (finalY + 40 > doc.internal.pageSize.height) { doc.addPage() @@ -286,6 +300,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to // Top Referrers Table if (topReferrers && topReferrers.length > 0) { + await updateProgress(++currentStep, totalSteps, 'Adding top referrers...') // Check if we need a new page if (finalY + 40 > doc.internal.pageSize.height) { doc.addPage() @@ -315,6 +330,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to // Campaigns Table if (campaigns && campaigns.length > 0) { + await updateProgress(++currentStep, totalSteps, 'Adding campaigns...') if (finalY + 40 > doc.internal.pageSize.height) { doc.addPage() finalY = 20 @@ -341,6 +357,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to }) } + await updateProgress(totalSteps, totalSteps, 'Saving PDF...') doc.save(`${filename || 'export'}.pdf`) onClose() return @@ -450,6 +467,22 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to )} + {/* Progress Bar */} + {isExporting && ( +
+
+ {exportProgress.label} + {Math.round((exportProgress.step / exportProgress.total) * 100)}% +
+
+
+
+
+ )} + {/* Actions */}
+ {/* Refetch indicator — visible when SWR is revalidating with stale data on screen */} + {dashboardValidating && !dashboardLoading && ( +
+
+
+ )} + {/* Advanced Chart with Integrated Stats */}
Date: Mon, 16 Mar 2026 11:40:32 +0100 Subject: [PATCH 005/428] chore: remove polish-audit.md from tracking --- docs/polish-audit.md | 80 -------------------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 docs/polish-audit.md diff --git a/docs/polish-audit.md b/docs/polish-audit.md deleted file mode 100644 index 9b6abcb..0000000 --- a/docs/polish-audit.md +++ /dev/null @@ -1,80 +0,0 @@ -# Pulse Polish Audit - -**Date:** 2026-03-16 -**Version:** 0.15.0-alpha - -## Current State - -Pulse is a mature product — 55+ routes, 156 API handlers, 50 migrations, 8 background workers, 3 third-party integrations. The codebase is clean with zero TODO/FIXME comments. Recent work has focused on UX refinements: empty state CTAs, animated number transitions, inline bar charts, and mobile responsiveness fixes. - -## Polish Opportunities - -### High Impact - -#### ~~1. Custom 404 Page~~ ✅ Already Done -- Branded 404 page exists at `app/not-found.tsx` with "Go back home" and "View FAQ" CTAs - -#### 2. Rate Limit (429) UX Feedback -- **Issue:** When rate limited, API requests fail silently with no user feedback -- **Effort:** Low -- **Action:** Catch 429 responses in the API client and show a toast with retry timing (`Retry-After` header) - -### Medium Impact - -#### 3. Export Progress Indicator -- **Issue:** PDF/Excel exports have no progress bar and can appear frozen on large datasets -- **Effort:** Low -- **Action:** Add a determinate or indeterminate progress bar inside `ExportModal.tsx` - -#### 4. Filter Application Feedback -- **Issue:** Applying filters has no loading/transition indicator -- **Effort:** Low -- **Action:** Show a subtle loading state on the dashboard when filters change and data is refetching - -#### 5. Inline Form Validation -- **Issue:** All validation errors go to toasts only; no inline field-level error messages -- **Effort:** Medium -- **Action:** Add inline error messages below form fields (funnel creation, goal creation, site settings) - -#### 6. Accessibility: Modal Focus Trapping -- **Issue:** Modals don't trap focus, breaking keyboard-only navigation -- **Effort:** Medium -- **Action:** Implement focus trapping in modal components (VerificationModal, ExportModal, settings modals) - -#### 7. Accessibility: ARIA Live Regions -- **Issue:** Real-time visitor count updates aren't announced to screen readers -- **Effort:** Low -- **Action:** Add `aria-live="polite"` to `RealtimeVisitors.tsx` and other auto-updating elements - -#### 8. Table Pagination Loading -- **Issue:** No loading indicator when paginating through table data -- **Effort:** Low -- **Action:** Show a loading spinner or skeleton overlay when fetching the next page of results - -### Low Impact - -#### 9. Remove Unused `axios` Dependency -- **Issue:** `apiRequest` is the actual HTTP client; `axios` is dead weight in the bundle -- **Effort:** Trivial -- **Action:** `npm uninstall axios` and verify no imports reference it - -#### 10. PWA Service Worker Caching -- **Issue:** `@ducanh2912/next-pwa` is configured but no offline caching strategy exists -- **Effort:** Medium -- **Action:** Define a caching strategy for static assets and API responses, or remove the PWA dependency - -#### 11. Image Lazy Loading -- **Issue:** Table/list images (favicons, flags) don't use `loading="lazy"` -- **Effort:** Low -- **Action:** Add `loading="lazy"` to images in referrer lists, country tables, and similar components - -#### 12. OpenAPI Specification -- **Issue:** Backend has no Swagger/OpenAPI doc; README serves as the only API documentation -- **Effort:** High -- **Action:** Generate an OpenAPI spec from Go handler annotations or write one manually - -## Additional Notes - -- **No i18n** — English only. Worth planning if expanding to international markets. -- **Test coverage** — 16 backend test files cover core logic well, but GSC/BunnyCDN sync and report delivery e2e are gaps. -- **Chart.tsx is 35KB** — A candidate for splitting into sub-components eventually. -- 2.49.1 From 336520e401658ffc6ea81e816c88c2b008aa70bf Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 11:48:47 +0100 Subject: [PATCH 006/428] feat: show brief success state before closing export modal Progress bar turns green at 100%, button shows "Done", then modal auto-closes after 600ms. Gives visual confirmation without fake delay. --- components/dashboard/ExportModal.tsx | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx index 87f3fc1..8993ab9 100644 --- a/components/dashboard/ExportModal.tsx +++ b/components/dashboard/ExportModal.tsx @@ -51,6 +51,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`) const [includeHeader, setIncludeHeader] = useState(true) const [isExporting, setIsExporting] = useState(false) + const [exportDone, setExportDone] = useState(false) const [exportProgress, setExportProgress] = useState({ step: 0, total: 1, label: '' }) const [selectedFields, setSelectedFields] = useState>({ date: true, @@ -64,6 +65,15 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to setSelectedFields((prev) => ({ ...prev, [field]: checked })) } + const finishExport = useCallback(() => { + setExportDone(true) + setIsExporting(false) + setTimeout(() => { + setExportDone(false) + onClose() + }, 600) + }, [onClose]) + // Yield to the UI thread so the browser can paint progress updates const updateProgress = useCallback(async (step: number, total: number, label: string) => { setExportProgress({ step, total, label }) @@ -134,7 +144,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to document.body.appendChild(link) link.click() document.body.removeChild(link) - onClose() + finishExport() return } else if (format === 'pdf') { const totalSteps = 3 + (topPages?.length ? 1 : 0) + (topReferrers?.length ? 1 : 0) + (campaigns?.length ? 1 : 0) @@ -359,7 +369,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to await updateProgress(totalSteps, totalSteps, 'Saving PDF...') doc.save(`${filename || 'export'}.pdf`) - onClose() + finishExport() return } else { content = JSON.stringify(exportData, null, 2) @@ -376,7 +386,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to link.click() document.body.removeChild(link) - onClose() + finishExport() } catch (e) { console.error('Export failed:', e) } finally { @@ -468,16 +478,16 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to )} {/* Progress Bar */} - {isExporting && ( + {(isExporting || exportDone) && (
- {exportProgress.label} - {Math.round((exportProgress.step / exportProgress.total) * 100)}% + {exportDone ? 'Export complete' : exportProgress.label} + {exportDone ? '100%' : `${Math.round((exportProgress.step / exportProgress.total) * 100)}%`}
@@ -488,8 +498,8 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to -
-- 2.49.1 From 4f419f8b0427c1e36296baa7b065aecc96ec04bc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 12:40:01 +0100 Subject: [PATCH 007/428] fix: increase inline bar chart opacity for better brand visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light mode: 5% → 15%, dark mode: 10% → 25% --- components/behavior/FrustrationByPageTable.tsx | 2 +- components/dashboard/Campaigns.tsx | 2 +- components/dashboard/ContentStats.tsx | 2 +- components/dashboard/Locations.tsx | 2 +- components/dashboard/TechSpecs.tsx | 2 +- components/dashboard/TopReferrers.tsx | 2 +- components/journeys/TopPathsTable.tsx | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/components/behavior/FrustrationByPageTable.tsx b/components/behavior/FrustrationByPageTable.tsx index 249da82..d8e3515 100644 --- a/components/behavior/FrustrationByPageTable.tsx +++ b/components/behavior/FrustrationByPageTable.tsx @@ -68,7 +68,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy > {/* Background bar */}
diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index 38e7209..4f37eaa 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -159,7 +159,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index 147963f..aad4c17 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -306,7 +306,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index d40f0ef..f18920e 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -191,7 +191,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index c8f5ce2..d7b5330 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -123,7 +123,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/journeys/TopPathsTable.tsx b/components/journeys/TopPathsTable.tsx index 72bdcd1..50660bb 100644 --- a/components/journeys/TopPathsTable.tsx +++ b/components/journeys/TopPathsTable.tsx @@ -61,7 +61,7 @@ export default function TopPathsTable({ paths, loading }: TopPathsTableProps) { > {/* Background bar */}
-- 2.49.1 From 3b09758881b395d8a6c528535b815266dc1772c1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 12:44:32 +0100 Subject: [PATCH 008/428] fix: cap inline bar chart max width at 75% Prevents the top item from spanning full width, making bars read more clearly as proportional indicators. --- components/behavior/FrustrationByPageTable.tsx | 2 +- components/dashboard/Campaigns.tsx | 2 +- components/dashboard/ContentStats.tsx | 2 +- components/dashboard/Locations.tsx | 2 +- components/dashboard/TechSpecs.tsx | 2 +- components/dashboard/TopReferrers.tsx | 2 +- components/journeys/TopPathsTable.tsx | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/components/behavior/FrustrationByPageTable.tsx b/components/behavior/FrustrationByPageTable.tsx index d8e3515..fa1169b 100644 --- a/components/behavior/FrustrationByPageTable.tsx +++ b/components/behavior/FrustrationByPageTable.tsx @@ -60,7 +60,7 @@ export default function FrustrationByPageTable({ pages, loading }: FrustrationBy {/* Rows */}
{pages.map((page) => { - const barWidth = (page.total / maxTotal) * 100 + const barWidth = (page.total / maxTotal) * 75 return (
{displayedData.map((item) => { const maxVis = displayedData[0]?.visitors ?? 0 - const barWidth = maxVis > 0 ? (item.visitors / maxVis) * 100 : 0 + const barWidth = maxVis > 0 ? (item.visitors / maxVis) * 75 : 0 return (
{displayedData.map((page, idx) => { const maxPv = displayedData[0]?.pageviews ?? 0 - const barWidth = maxPv > 0 ? (page.pageviews / maxPv) * 100 : 0 + const barWidth = maxPv > 0 ? (page.pageviews / maxPv) * 75 : 0 return (
0 ? (item.pageviews / maxPv) * 100 : 0 + const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 75 : 0 return (
0 ? (item.pageviews / maxPv) * 100 : 0 + const barWidth = maxPv > 0 ? (item.pageviews / maxPv) * 75 : 0 return (
{displayedReferrers.map((ref) => { const maxPv = displayedReferrers[0]?.pageviews ?? 0 - const barWidth = maxPv > 0 ? (ref.pageviews / maxPv) * 100 : 0 + const barWidth = maxPv > 0 ? (ref.pageviews / maxPv) * 75 : 0 return (
{paths.map((path, i) => { - const barWidth = maxCount > 0 ? (path.session_count / maxCount) * 100 : 0 + const barWidth = maxCount > 0 ? (path.session_count / maxCount) * 75 : 0 const displaySeq = truncateSequence(path.page_sequence, 7) return ( -- 2.49.1 From 47ea6fa6f6f2e43ba1007d4f6548a2f4cb791147 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 13:28:13 +0100 Subject: [PATCH 009/428] feat: add micro-animations to journey chart - Connection lines draw-in with staggered stroke-dashoffset - Bar widths grow from zero on mount with row stagger - Columns fade + slide in from left with 50ms delay each - Hover lift (-1px translate + shadow) on page rows - Exit card fades in from top - Drop-off percentages count up with eased animation --- components/journeys/ColumnJourney.tsx | 137 +++++++++++++++++++++----- 1 file changed, 115 insertions(+), 22 deletions(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index bae0d9c..1646450 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -1,6 +1,6 @@ 'use client' -import { Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { TreeStructure } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' @@ -53,6 +53,34 @@ function smartLabel(path: string): string { return `…/${segments[segments.length - 1]}` } +// ─── Animated count hook ──────────────────────────────────────────── + +function useAnimatedCount(target: number, duration = 400): number { + const [display, setDisplay] = useState(0) + const prevTarget = useRef(target) + + useEffect(() => { + const from = prevTarget.current + prevTarget.current = target + if (from === target) { + setDisplay(target) + return + } + const start = performance.now() + let raf: number + const tick = (now: number) => { + const t = Math.min((now - start) / duration, 1) + const eased = 1 - Math.pow(1 - t, 3) // ease-out cubic + setDisplay(Math.round(from + (target - from) * eased)) + if (t < 1) raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [target, duration]) + + return display +} + // ─── Data transformation ──────────────────────────────────────────── function buildColumns( @@ -112,6 +140,21 @@ function buildColumns( // ─── Sub-components ───────────────────────────────────────────────── +function AnimatedDropOff({ percent }: { percent: number }) { + const displayed = useAnimatedCount(percent) + if (displayed === 0 && percent === 0) return null + return ( + + {percent > 0 ? '+' : displayed < 0 ? '' : ''} + {displayed}% + + ) +} + function ColumnHeader({ column, }: { @@ -127,14 +170,7 @@ function ColumnHeader({ {column.totalSessions.toLocaleString()} visitors {column.dropOffPercent !== 0 && ( - - {column.dropOffPercent > 0 ? '+' : ''} - {column.dropOffPercent}% - + )}
@@ -144,18 +180,22 @@ function ColumnHeader({ function PageRow({ page, colIndex, + rowIndex, columnTotal, maxCount, isSelected, isOther, + isMounted, onClick, }: { page: ColumnPage colIndex: number + rowIndex: number columnTotal: number maxCount: number isSelected: boolean isOther: boolean + isMounted: boolean onClick: () => void }) { const pct = columnTotal > 0 ? Math.round((page.sessionCount / columnTotal) * 100) : 0 @@ -171,22 +211,23 @@ function PageRow({ data-path={page.path} className={` group flex items-center justify-between w-full relative - h-9 px-3 rounded-lg text-left transition-colors + h-9 px-3 rounded-lg text-left transition-all duration-200 ${isOther ? 'cursor-default' : 'cursor-pointer'} ${isSelected ? 'bg-brand-orange/10 dark:bg-brand-orange/10' : isOther ? '' - : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50' + : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50 hover:-translate-y-px hover:shadow-sm' } `} > - {/* Background bar */} + {/* Background bar — animates width on mount */} {!isOther && barWidth > 0 && (
@@ -233,9 +274,24 @@ function JourneyColumn({ exitCount: number onSelect: (path: string) => void }) { + // Animation #2 & #3: trigger bar grow after mount + const [isMounted, setIsMounted] = useState(false) + useEffect(() => { + const raf = requestAnimationFrame(() => setIsMounted(true)) + return () => { + cancelAnimationFrame(raf) + setIsMounted(false) + } + }, [column.pages]) + if (column.pages.length === 0 && exitCount === 0) { return ( -
+
@@ -249,31 +305,40 @@ function JourneyColumn({ const maxCount = Math.max(...column.pages.map((p) => p.sessionCount), 0) return ( -
+
- {column.pages.map((page) => { + {column.pages.map((page, rowIndex) => { const isOther = page.path === '(other)' return ( { if (!isOther) onSelect(page.path) }} /> ) })} + {/* Animation #5: exit card slides in */} {exitCount > 0 && (
([]) + const [lines, setLines] = useState<(LineDef & { color: string; length: number })[]>([]) const [dimensions, setDimensions] = useState({ width: 0, height: 0 }) useLayoutEffect(() => { @@ -324,7 +389,7 @@ function ConnectionLines({ height: container.scrollHeight, }) - const newLines: (LineDef & { color: string })[] = [] + const newLines: (LineDef & { color: string; length: number })[] = [] for (const [colIdx, selectedPath] of selections) { const nextCol = columns[colIdx + 1] @@ -362,7 +427,12 @@ function ConnectionLines({ const weight = Math.max(1, Math.min(4, (t.session_count / maxCount) * 4)) - newLines.push({ sourceY, destY, sourceX, destX, weight, color }) + // Approximate bezier curve length for animation + const dx = destX - sourceX + const dy = destY - sourceY + const length = Math.sqrt(dx * dx + dy * dy) * 1.2 + + newLines.push({ sourceY, destY, sourceX, destX, weight, color, length }) } // Draw line to exit card if it exists @@ -374,7 +444,10 @@ function ConnectionLines({ const exitY = exitRect.top + exitRect.height / 2 - containerRect.top + container.scrollTop const exitX = exitRect.left - containerRect.left + container.scrollLeft - newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444' }) + const dx = exitX - sourceX + const dy = exitY - sourceY + const length = Math.sqrt(dx * dx + dy * dy) * 1.2 + newLines.push({ sourceY, destY: exitY, sourceX, destX: exitX, weight: 1, color: '#ef4444', length }) } } @@ -400,9 +473,19 @@ function ConnectionLines({ strokeWidth={line.weight} strokeOpacity={0.35} fill="none" + strokeDasharray={line.length} + strokeDashoffset={line.length} + style={{ + animation: `draw-line 400ms ease-out ${i * 50}ms forwards`, + }} /> ) })} + ) } @@ -488,6 +571,16 @@ export default function ColumnJourney({ return (
+
{i > 0 && ( -
+
)} Date: Mon, 16 Mar 2026 13:57:06 +0100 Subject: [PATCH 010/428] chore: add @nivo/sankey dependency --- package-lock.json | 346 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 347 insertions(+) diff --git a/package-lock.json b/package-lock.json index b0a4afe..55577fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ciphera-net/ui": "^0.2.7", "@ducanh2912/next-pwa": "^10.2.9", + "@nivo/sankey": "^0.99.0", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", @@ -3269,6 +3270,174 @@ "node": ">= 10" } }, + "node_modules/@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "license": "MIT", + "dependencies": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/core/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/@nivo/core/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/core/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/sankey": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/sankey/-/sankey-0.99.0.tgz", + "integrity": "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-sankey": "^0.11.2", + "@types/d3-shape": "^3.1.6", + "d3-sankey": "^0.12.3", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4825,6 +4994,78 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -5625,6 +5866,30 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, + "node_modules/@types/d3-sankey": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", + "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -5634,6 +5899,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -7858,6 +8129,46 @@ "node": ">=12" } }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -7874,6 +8185,19 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -12693,6 +13017,16 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14643,6 +14977,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz", + "integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/package.json b/package.json index cda1d08..bd8319e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@ciphera-net/ui": "^0.2.7", "@ducanh2912/next-pwa": "^10.2.9", + "@nivo/sankey": "^0.99.0", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", -- 2.49.1 From 71f922976dc3a5dd29b07329a21870966b10f70b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 14:00:12 +0100 Subject: [PATCH 011/428] feat: add SankeyJourney component with data transformation and interactivity --- components/journeys/SankeyJourney.tsx | 347 ++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 components/journeys/SankeyJourney.tsx diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx new file mode 100644 index 0000000..aa0ff92 --- /dev/null +++ b/components/journeys/SankeyJourney.tsx @@ -0,0 +1,347 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { ResponsiveSankey } from '@nivo/sankey' +import { TreeStructure, X } from '@phosphor-icons/react' +import type { PathTransition } from '@/lib/api/journeys' + +// ─── Types ────────────────────────────────────────────────────────── + +interface SankeyJourneyProps { + transitions: PathTransition[] + totalSessions: number + depth: number +} + +interface SankeyNode { + id: string + stepIndex: number +} + +interface SankeyLink { + source: string + target: string + value: number +} + +interface SankeyData { + nodes: SankeyNode[] + links: SankeyLink[] +} + +// ─── Constants ────────────────────────────────────────────────────── + +const COLUMN_COLORS = [ + '#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', + '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1', +] + +const MAX_NODES_PER_STEP = 15 + +// ─── Helpers ──────────────────────────────────────────────────────── + +function smartLabel(path: string): string { + if (path === '/' || path === '(other)') return path + const segments = path.replace(/\/$/, '').split('/') + if (segments.length <= 2) return path + return `.../${segments[segments.length - 1]}` +} + +/** Extract the original path from a step-prefixed node id like "0:/blog" */ +function pathFromId(id: string): string { + const idx = id.indexOf(':') + return idx >= 0 ? id.slice(idx + 1) : id +} + +/** Extract the step index from a step-prefixed node id */ +function stepFromId(id: string): number { + const idx = id.indexOf(':') + return idx >= 0 ? parseInt(id.slice(0, idx), 10) : 0 +} + +// ─── Data Transformation ──────────────────────────────────────────── + +function buildSankeyData( + transitions: PathTransition[], + filterPath?: string, +): SankeyData { + if (transitions.length === 0) return { nodes: [], links: [] } + + // Group transitions by step and count sessions per path at each step + const stepPaths = new Map>() + + for (const t of transitions) { + // from_path at step_index + if (!stepPaths.has(t.step_index)) stepPaths.set(t.step_index, new Map()) + const fromMap = stepPaths.get(t.step_index)! + fromMap.set(t.from_path, (fromMap.get(t.from_path) ?? 0) + t.session_count) + + // to_path at step_index + 1 + const nextStep = t.step_index + 1 + if (!stepPaths.has(nextStep)) stepPaths.set(nextStep, new Map()) + const toMap = stepPaths.get(nextStep)! + toMap.set(t.to_path, (toMap.get(t.to_path) ?? 0) + t.session_count) + } + + // For each step, keep top N paths, group rest into (other) + const topPathsPerStep = new Map>() + for (const [step, pathMap] of stepPaths) { + const sorted = Array.from(pathMap.entries()).sort((a, b) => b[1] - a[1]) + const kept = new Set(sorted.slice(0, MAX_NODES_PER_STEP).map(([p]) => p)) + topPathsPerStep.set(step, kept) + } + + // Build links with capping + const linkMap = new Map() + for (const t of transitions) { + const fromStep = t.step_index + const toStep = t.step_index + 1 + const fromTop = topPathsPerStep.get(fromStep)! + const toTop = topPathsPerStep.get(toStep)! + + const fromPath = fromTop.has(t.from_path) ? t.from_path : '(other)' + const toPath = toTop.has(t.to_path) ? t.to_path : '(other)' + + // Skip self-links where both collapse to (other) + if (fromPath === '(other)' && toPath === '(other)') continue + + const sourceId = `${fromStep}:${fromPath}` + const targetId = `${toStep}:${toPath}` + const key = `${sourceId}|${targetId}` + linkMap.set(key, (linkMap.get(key) ?? 0) + t.session_count) + } + + let links: SankeyLink[] = Array.from(linkMap.entries()).map(([key, value]) => { + const [source, target] = key.split('|') + return { source, target, value } + }) + + // Collect all node ids referenced by links + const nodeIdSet = new Set() + for (const link of links) { + nodeIdSet.add(link.source) + nodeIdSet.add(link.target) + } + + let nodes: SankeyNode[] = Array.from(nodeIdSet).map((id) => ({ + id, + stepIndex: stepFromId(id), + })) + + // ─── Filter by path (BFS forward + backward) ──────────────────── + if (filterPath) { + const matchingNodeIds = nodes + .filter((n) => pathFromId(n.id) === filterPath) + .map((n) => n.id) + + if (matchingNodeIds.length === 0) return { nodes: [], links: [] } + + // Build adjacency + const forwardAdj = new Map>() + const backwardAdj = new Map>() + for (const link of links) { + if (!forwardAdj.has(link.source)) forwardAdj.set(link.source, new Set()) + forwardAdj.get(link.source)!.add(link.target) + if (!backwardAdj.has(link.target)) backwardAdj.set(link.target, new Set()) + backwardAdj.get(link.target)!.add(link.source) + } + + const reachable = new Set(matchingNodeIds) + + // BFS forward + let queue = [...matchingNodeIds] + while (queue.length > 0) { + const next: string[] = [] + for (const nodeId of queue) { + for (const neighbor of forwardAdj.get(nodeId) ?? []) { + if (!reachable.has(neighbor)) { + reachable.add(neighbor) + next.push(neighbor) + } + } + } + queue = next + } + + // BFS backward + queue = [...matchingNodeIds] + while (queue.length > 0) { + const next: string[] = [] + for (const nodeId of queue) { + for (const neighbor of backwardAdj.get(nodeId) ?? []) { + if (!reachable.has(neighbor)) { + reachable.add(neighbor) + next.push(neighbor) + } + } + } + queue = next + } + + links = links.filter( + (l) => reachable.has(l.source) && reachable.has(l.target), + ) + + const filteredNodeIds = new Set() + for (const link of links) { + filteredNodeIds.add(link.source) + filteredNodeIds.add(link.target) + } + nodes = nodes.filter((n) => filteredNodeIds.has(n.id)) + } + + return { nodes, links } +} + +// ─── Component ────────────────────────────────────────────────────── + +export default function SankeyJourney({ + transitions, + totalSessions, + depth, +}: SankeyJourneyProps) { + const [filterPath, setFilterPath] = useState(null) + const [isDark, setIsDark] = useState(false) + + // Reactively detect dark mode via MutationObserver + useEffect(() => { + const el = document.documentElement + setIsDark(el.classList.contains('dark')) + + const observer = new MutationObserver(() => { + setIsDark(el.classList.contains('dark')) + }) + observer.observe(el, { attributes: true, attributeFilter: ['class'] }) + return () => observer.disconnect() + }, []) + + const data = useMemo( + () => buildSankeyData(transitions, filterPath ?? undefined), + [transitions, filterPath], + ) + + const handleClick = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any) => { + if (!item.id || typeof item.id !== 'string') return // link click, ignore + const path = pathFromId(item.id) + if (path === '(other)') return + setFilterPath((prev) => (prev === path ? null : path)) + }, + [], + ) + + // Clear filter when data changes + const transitionsKey = transitions.length + '-' + depth + const [prevKey, setPrevKey] = useState(transitionsKey) + if (prevKey !== transitionsKey) { + setPrevKey(transitionsKey) + if (filterPath !== null) setFilterPath(null) + } + + // ─── Empty state ──────────────────────────────────────────────── + if (!transitions.length || data.nodes.length === 0) { + return ( +
+
+ +
+

+ No journey data yet +

+

+ Navigation flows will appear here as visitors browse through your site. +

+
+ ) + } + + const labelColor = isDark ? '#a3a3a3' : '#525252' + + return ( +
+ {/* Filter reset bar */} + {filterPath && ( +
+ + Showing flows through{' '} + + {filterPath} + + + +
+ )} + +
+ + data={data} + margin={{ top: 16, right: 140, bottom: 16, left: 140 }} + align="justify" + sort="descending" + colors={(node) => + COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length] + } + nodeThickness={14} + nodeSpacing={16} + nodeInnerPadding={0} + nodeBorderWidth={0} + nodeBorderRadius={3} + nodeOpacity={1} + nodeHoverOpacity={1} + nodeHoverOthersOpacity={0.3} + linkOpacity={0.2} + linkHoverOpacity={0.5} + linkHoverOthersOpacity={0.05} + linkContract={1} + enableLinkGradient + enableLabels + label={(node) => smartLabel(pathFromId(node.id))} + labelPosition="outside" + labelPadding={12} + labelTextColor={labelColor} + isInteractive + onClick={handleClick} + nodeTooltip={({ node }) => ( +
+
+ {pathFromId(node.id)} +
+
+ Step {node.stepIndex + 1} ·{' '} + {node.value.toLocaleString()} sessions +
+
+ )} + linkTooltip={({ link }) => ( +
+
+ {pathFromId(link.source.id)} →{' '} + {pathFromId(link.target.id)} +
+
+ {link.value.toLocaleString()} sessions +
+
+ )} + theme={{ + tooltip: { + container: { + background: 'transparent', + boxShadow: 'none', + padding: 0, + }, + }, + }} + /> +
+
+ ) +} -- 2.49.1 From 6f42d4d3def03204ee27dc661232cb634fab2b7c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 14:01:18 +0100 Subject: [PATCH 012/428] feat: add Columns/Flow view toggle to journeys page --- app/sites/[id]/journeys/page.tsx | 49 ++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index ccf3ebc..f1ddb9d 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -2,9 +2,11 @@ import { useEffect, useState } from 'react' import { useParams } from 'next/navigation' +import { motion } from 'framer-motion' import { getDateRange, formatDate } from '@ciphera-net/ui' import { Select, DatePicker } from '@ciphera-net/ui' import ColumnJourney from '@/components/journeys/ColumnJourney' +import SankeyJourney from '@/components/journeys/SankeyJourney' import TopPathsTable from '@/components/journeys/TopPathsTable' import { JourneysSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { @@ -40,6 +42,7 @@ export default function JourneysPage() { const [depth, setDepth] = useState(DEFAULT_DEPTH) const [committedDepth, setCommittedDepth] = useState(DEFAULT_DEPTH) const [entryPath, setEntryPath] = useState('') + const [viewMode, setViewMode] = useState<'columns' | 'flow'>('columns') useEffect(() => { const t = setTimeout(() => setCommittedDepth(depth), 300) @@ -182,15 +185,49 @@ export default function JourneysPage() {
+ + {/* View toggle */} +
+ {(['columns', 'flow'] as const).map((mode) => ( + + ))} +
- {/* Journey Columns */} + {/* Journey Chart */}
- + {viewMode === 'columns' ? ( + + ) : ( + + )}
{/* Footer */} -- 2.49.1 From d3f5e6b361348a630eba0f0b42e3d8afa0bd1ce3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 14:08:08 +0100 Subject: [PATCH 013/428] fix: disable sankey labels, reduce margins, dynamic height Labels were overlapping badly with many nodes. Rely on hover tooltips instead. Chart height now scales with node count (400-700px range). --- components/journeys/SankeyJourney.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index aa0ff92..84875a7 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -280,17 +280,17 @@ export default function SankeyJourney({
)} -
+
data={data} - margin={{ top: 16, right: 140, bottom: 16, left: 140 }} + margin={{ top: 8, right: 8, bottom: 8, left: 8 }} align="justify" sort="descending" colors={(node) => COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length] } - nodeThickness={14} - nodeSpacing={16} + nodeThickness={12} + nodeSpacing={20} nodeInnerPadding={0} nodeBorderWidth={0} nodeBorderRadius={3} @@ -300,13 +300,9 @@ export default function SankeyJourney({ linkOpacity={0.2} linkHoverOpacity={0.5} linkHoverOthersOpacity={0.05} - linkContract={1} + linkContract={2} enableLinkGradient - enableLabels - label={(node) => smartLabel(pathFromId(node.id))} - labelPosition="outside" - labelPadding={12} - labelTextColor={labelColor} + enableLabels={false} isInteractive onClick={handleClick} nodeTooltip={({ node }) => ( -- 2.49.1 From 1aace48d7304087c5b6e53dea3f67bb43a6a9363 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 14:15:10 +0100 Subject: [PATCH 014/428] fix: cap sankey height at 500px, show labels for first/last steps only --- components/journeys/SankeyJourney.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index 84875a7..d1958cd 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -257,6 +257,9 @@ export default function SankeyJourney({ } const labelColor = isDark ? '#a3a3a3' : '#525252' + const steps = data.nodes.map((n) => n.stepIndex) + const minStep = Math.min(...steps) + const maxStep = Math.max(...steps) return (
@@ -280,10 +283,10 @@ export default function SankeyJourney({
)} -
+
data={data} - margin={{ top: 8, right: 8, bottom: 8, left: 8 }} + margin={{ top: 8, right: 140, bottom: 8, left: 140 }} align="justify" sort="descending" colors={(node) => @@ -302,7 +305,17 @@ export default function SankeyJourney({ linkHoverOthersOpacity={0.05} linkContract={2} enableLinkGradient - enableLabels={false} + enableLabels + label={(node) => { + // Only show labels for first and last step columns + if (node.stepIndex === minStep || node.stepIndex === maxStep) { + return smartLabel(pathFromId(node.id)) + } + return '' + }} + labelPosition="outside" + labelPadding={8} + labelTextColor={labelColor} isInteractive onClick={handleClick} nodeTooltip={({ node }) => ( -- 2.49.1 From f797d89131041eb82fed47243ddf1768a7c94484 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 14:22:06 +0100 Subject: [PATCH 015/428] fix: restyle sankey to match reference - thinner nodes, all labels, scrollable - Switch to fixed-width Sankey with horizontal scroll (250px per step) - Thinner nodes (8px), tighter spacing (8px) - Labels on all columns, not just first/last - Lower link opacity (0.15) for cleaner look - Increased node cap to 25 per step --- components/journeys/SankeyJourney.tsx | 136 +++++++++++++------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index d1958cd..b084b66 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { ResponsiveSankey } from '@nivo/sankey' +import { Sankey } from '@nivo/sankey' import { TreeStructure, X } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' @@ -36,7 +36,7 @@ const COLUMN_COLORS = [ '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1', ] -const MAX_NODES_PER_STEP = 15 +const MAX_NODES_PER_STEP = 25 // ─── Helpers ──────────────────────────────────────────────────────── @@ -257,9 +257,11 @@ export default function SankeyJourney({ } const labelColor = isDark ? '#a3a3a3' : '#525252' - const steps = data.nodes.map((n) => n.stepIndex) - const minStep = Math.min(...steps) - const maxStep = Math.max(...steps) + + // Calculate dimensions: give each step ~250px of horizontal space + const numSteps = new Set(data.nodes.map((n) => n.stepIndex)).size + const chartWidth = Math.max(800, numSteps * 250) + const chartHeight = 500 return (
@@ -283,73 +285,71 @@ export default function SankeyJourney({
)} -
- - data={data} - margin={{ top: 8, right: 140, bottom: 8, left: 140 }} - align="justify" - sort="descending" - colors={(node) => - COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length] - } - nodeThickness={12} - nodeSpacing={20} - nodeInnerPadding={0} - nodeBorderWidth={0} - nodeBorderRadius={3} - nodeOpacity={1} - nodeHoverOpacity={1} - nodeHoverOthersOpacity={0.3} - linkOpacity={0.2} - linkHoverOpacity={0.5} - linkHoverOthersOpacity={0.05} - linkContract={2} - enableLinkGradient - enableLabels - label={(node) => { - // Only show labels for first and last step columns - if (node.stepIndex === minStep || node.stepIndex === maxStep) { - return smartLabel(pathFromId(node.id)) +
+
+ + data={data} + width={chartWidth} + height={chartHeight} + margin={{ top: 8, right: 160, bottom: 8, left: 160 }} + align="justify" + sort="descending" + colors={(node) => + COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length] } - return '' - }} - labelPosition="outside" - labelPadding={8} - labelTextColor={labelColor} - isInteractive - onClick={handleClick} - nodeTooltip={({ node }) => ( -
-
- {pathFromId(node.id)} + nodeThickness={8} + nodeSpacing={8} + nodeInnerPadding={0} + nodeBorderWidth={0} + nodeBorderRadius={2} + nodeOpacity={1} + nodeHoverOpacity={1} + nodeHoverOthersOpacity={0.3} + linkOpacity={0.15} + linkHoverOpacity={0.5} + linkHoverOthersOpacity={0.03} + linkContract={1} + enableLinkGradient + enableLabels + label={(node) => smartLabel(pathFromId(node.id))} + labelPosition="outside" + labelPadding={6} + labelTextColor={labelColor} + isInteractive + onClick={handleClick} + nodeTooltip={({ node }) => ( +
+
+ {pathFromId(node.id)} +
+
+ Step {node.stepIndex + 1} ·{' '} + {node.value.toLocaleString()} sessions +
-
- Step {node.stepIndex + 1} ·{' '} - {node.value.toLocaleString()} sessions + )} + linkTooltip={({ link }) => ( +
+
+ {pathFromId(link.source.id)} →{' '} + {pathFromId(link.target.id)} +
+
+ {link.value.toLocaleString()} sessions +
-
- )} - linkTooltip={({ link }) => ( -
-
- {pathFromId(link.source.id)} →{' '} - {pathFromId(link.target.id)} -
-
- {link.value.toLocaleString()} sessions -
-
- )} - theme={{ - tooltip: { - container: { - background: 'transparent', - boxShadow: 'none', - padding: 0, + )} + theme={{ + tooltip: { + container: { + background: 'transparent', + boxShadow: 'none', + padding: 0, + }, }, - }, - }} - /> + }} + /> +
) -- 2.49.1 From e444985295c7743b1bb1c485349fcd41b8941bad Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 16:59:37 +0100 Subject: [PATCH 016/428] refactor: extract frustration tracking into separate add-on script Move rage click and dead click detection (35% of script.js) into script.frustration.js as an optional add-on. Core script drops from 8.1KB to 5.7KB gzipped. Add-on auto-discovers core via window.pulse polling and supports opt-out via data-no-rage/data-no-dead attributes. - Expose cleanPath on window.pulse for add-on consumption - Add script.frustration.js to middleware PUBLIC_ROUTES - Update integration guides, ScriptSetupBlock, and FrustrationTable empty state to reference the add-on script --- components/IntegrationGuide.tsx | 12 + components/behavior/FrustrationTable.tsx | 5 +- components/sites/ScriptSetupBlock.tsx | 3 + middleware.ts | 1 + public/script.frustration.js | 313 +++++++++++++++++++++++ public/script.js | 271 +------------------- 6 files changed, 334 insertions(+), 271 deletions(-) create mode 100644 public/script.frustration.js diff --git a/components/IntegrationGuide.tsx b/components/IntegrationGuide.tsx index efca796..fb53861 100644 --- a/components/IntegrationGuide.tsx +++ b/components/IntegrationGuide.tsx @@ -67,6 +67,18 @@ export function IntegrationGuide({ integration, children }: IntegrationGuideProp
{children} + +
+

Optional: Frustration Tracking

+

+ Detect rage clicks and dead clicks by adding the frustration tracking + add-on after the core script: +

+
{``}
+

+ No extra configuration needed. Add data-no-rage or{' '} + data-no-dead to disable individual signals. +

{/* * --- Related Integrations --- */} diff --git a/components/behavior/FrustrationTable.tsx b/components/behavior/FrustrationTable.tsx index 8294b95..916b749 100644 --- a/components/behavior/FrustrationTable.tsx +++ b/components/behavior/FrustrationTable.tsx @@ -184,8 +184,11 @@ export default function FrustrationTable({ No {title.toLowerCase()} detected

- {description}. Data will appear here once frustration signals are detected on your site. + Frustration tracking requires the add-on script. Add it after your core Pulse script:

+ + {''} +
)}
diff --git a/components/sites/ScriptSetupBlock.tsx b/components/sites/ScriptSetupBlock.tsx index b853f27..fe3c44d 100644 --- a/components/sites/ScriptSetupBlock.tsx +++ b/components/sites/ScriptSetupBlock.tsx @@ -92,6 +92,9 @@ export default function ScriptSetupBlock({

Default: cross-tab (localStorage). Optional: data-storage="session" to opt out (per-tab, ephemeral). Optional: data-storage-ttl="48" to set expiry in hours (default: 24).

+

+ Optional: add {``} for rage click and dead click detection. +

- ))} -
-

- - View all integrations → - -

- - )} - -
- - {``} - -

- Default: cross-tab (localStorage). Optional: data-storage="session" to opt out (per-tab, ephemeral). Optional: data-storage-ttl="48" to set expiry in hours (default: 24). -

-

- Optional: add {``} for rage click and dead click detection. -

- + {/* ── Script snippet ──────────────────────────────────────────────── */} +
+
+ + Your tracking script + + +
+
+          {scriptSnippet}
+        
- {showFrameworkPicker && selectedIntegrationSlug && getIntegration(selectedIntegrationSlug) && ( -

- - See full {getIntegration(selectedIntegrationSlug)!.name} guide → - -

+ {/* ── Feature toggles ─────────────────────────────────────────────── */} +
+

+ Features +

+
+ {FEATURES.map((f) => ( +
+
+ + {f.label} + + + {f.description} + +
+ toggleFeature(f.key)} /> +
+ ))} +
+ {/* * Frustration — full-width, visually distinct as add-on */} +
+
+ + Frustration tracking + + + Rage clicks & dead clicks · Loads separate add-on script + +
+ toggleFeature('frustration')} /> +
+
+ + {/* ── Storage + TTL ───────────────────────────────────────────────── */} +
+

+ Visitor identity +

+
+
+ + +
+ )} +
+
+ + {/* ── Framework guide ─────────────────────────────────────────────── */} + {showFrameworkPicker && ( +
+

+ Setup guide +

+
+
+
{storage === 'local' && ( -
+
-
- {selectedIntegration && ( - - See {selectedIntegration.name} guide → - - )} - {!selectedIntegration && ( - - All integrations → - - )} +
+

+ Setup guide +

+ + All integrations → +
+
+ {FRAMEWORKS.map((fw) => ( + + ))} +
+ {selectedIntegration && ( + + See full {selectedIntegration.name} guide → + + )}
)}
-- 2.49.1 From 40f223cf38b0ceab6604707e0136c5b071ddf6bf Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 21:25:21 +0100 Subject: [PATCH 019/428] =?UTF-8?q?fix:=20make=20sankey=20chart=20responsi?= =?UTF-8?q?ve=20=E2=80=94=20no=20horizontal=20scrolling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fixed numSteps*250 width calculation with ResizeObserver that measures the container and fits the chart within it. --- components/journeys/SankeyJourney.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index b084b66..23667b9 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Sankey } from '@nivo/sankey' import { TreeStructure, X } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' @@ -258,10 +258,20 @@ export default function SankeyJourney({ const labelColor = isDark ? '#a3a3a3' : '#525252' - // Calculate dimensions: give each step ~250px of horizontal space - const numSteps = new Set(data.nodes.map((n) => n.stepIndex)).size - const chartWidth = Math.max(800, numSteps * 250) const chartHeight = 500 + const containerRef = useRef(null) + const [chartWidth, setChartWidth] = useState(800) + + // * Measure container width and resize chart to fit without horizontal scroll + useEffect(() => { + const el = containerRef.current + if (!el) return + const measure = () => setChartWidth(el.clientWidth) + measure() + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => observer.disconnect() + }, []) return (
@@ -285,7 +295,7 @@ export default function SankeyJourney({
)} -
+
data={data} -- 2.49.1 From bec61c599e677f261287f80b8be6cdb0e8737fd9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 21:30:09 +0100 Subject: [PATCH 020/428] =?UTF-8?q?fix:=20reduce=20sankey=20margins=20from?= =?UTF-8?q?=20160px=20to=2090px=20=E2=80=94=20less=20wasted=20space?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/journeys/SankeyJourney.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index 23667b9..cc2b268 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -301,7 +301,7 @@ export default function SankeyJourney({ data={data} width={chartWidth} height={chartHeight} - margin={{ top: 8, right: 160, bottom: 8, left: 160 }} + margin={{ top: 8, right: 90, bottom: 8, left: 90 }} align="justify" sort="descending" colors={(node) => -- 2.49.1 From 4007056e44909870199e21965872063e8200e3eb Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 21:45:08 +0100 Subject: [PATCH 021/428] feat: redesign sankey to block-style nodes with inside labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nodeThickness 8 → 100 (wide blocks like Rybbit) - Labels inside nodes with white text instead of outside - Margins 90px → 16px (labels no longer need outside space) - Dynamic chart height based on max nodes per step - Tighter nodeSpacing (4px) and subtle link opacity - nodeBorderRadius 4 for rounded block corners --- components/journeys/SankeyJourney.tsx | 34 ++++++++++++++++----------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index cc2b268..fe5b858 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -258,7 +258,13 @@ export default function SankeyJourney({ const labelColor = isDark ? '#a3a3a3' : '#525252' - const chartHeight = 500 + // * Scale height based on max nodes in any step so blocks aren't too compressed + const stepCounts = new Map() + for (const n of data.nodes) { + stepCounts.set(n.stepIndex, (stepCounts.get(n.stepIndex) ?? 0) + 1) + } + const maxNodesInStep = Math.max(1, ...Array.from(stepCounts.values())) + const chartHeight = Math.max(400, Math.min(700, maxNodesInStep * 45)) const containerRef = useRef(null) const [chartWidth, setChartWidth] = useState(800) @@ -301,30 +307,30 @@ export default function SankeyJourney({ data={data} width={chartWidth} height={chartHeight} - margin={{ top: 8, right: 90, bottom: 8, left: 90 }} + margin={{ top: 8, right: 16, bottom: 8, left: 16 }} align="justify" sort="descending" colors={(node) => COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length] } - nodeThickness={8} - nodeSpacing={8} - nodeInnerPadding={0} + nodeThickness={100} + nodeSpacing={4} + nodeInnerPadding={2} nodeBorderWidth={0} - nodeBorderRadius={2} - nodeOpacity={1} + nodeBorderRadius={4} + nodeOpacity={0.9} nodeHoverOpacity={1} nodeHoverOthersOpacity={0.3} - linkOpacity={0.15} - linkHoverOpacity={0.5} - linkHoverOthersOpacity={0.03} - linkContract={1} + linkOpacity={0.12} + linkHoverOpacity={0.4} + linkHoverOthersOpacity={0.02} + linkContract={2} enableLinkGradient enableLabels label={(node) => smartLabel(pathFromId(node.id))} - labelPosition="outside" - labelPadding={6} - labelTextColor={labelColor} + labelPosition="inside" + labelPadding={8} + labelTextColor="#ffffff" isInteractive onClick={handleClick} nodeTooltip={({ node }) => ( -- 2.49.1 From 17f2bdc9e90a5d2ee44c559119e72ac734eaf368 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 21:56:22 +0100 Subject: [PATCH 022/428] =?UTF-8?q?feat:=20rewrite=20sankey=20chart=20with?= =?UTF-8?q?=20D3=20=E2=80=94=20thin=20bars,=20labels=20beside=20nodes,=20p?= =?UTF-8?q?roper=20hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace @nivo/sankey with custom D3 implementation: - 30px thin node bars with labels positioned beside them - Links at 0.3 opacity, 0.6 on hover with full path highlighting - Colors based on first URL segment for visual grouping - Dynamic height based on tallest column - Responsive width via ResizeObserver - Click nodes to filter, hover for tooltips - Invisible wider hit areas for easier link hovering - Remove @nivo/sankey dependency, add d3 --- components/journeys/SankeyJourney.tsx | 629 ++++++++++++------- package-lock.json | 835 ++++++++++++++++---------- package.json | 3 +- 3 files changed, 914 insertions(+), 553 deletions(-) diff --git a/components/journeys/SankeyJourney.tsx b/components/journeys/SankeyJourney.tsx index fe5b858..e097cf0 100644 --- a/components/journeys/SankeyJourney.tsx +++ b/components/journeys/SankeyJourney.tsx @@ -1,7 +1,7 @@ 'use client' +import * as d3 from 'd3' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Sankey } from '@nivo/sankey' import { TreeStructure, X } from '@phosphor-icons/react' import type { PathTransition } from '@/lib/api/journeys' @@ -13,33 +13,59 @@ interface SankeyJourneyProps { depth: number } -interface SankeyNode { +interface SNode { id: string - stepIndex: number + name: string + step: number + height: number + x: number + y: number + count: number + inLinks: SLink[] + outLinks: SLink[] } -interface SankeyLink { +interface SLink { source: string target: string value: number -} - -interface SankeyData { - nodes: SankeyNode[] - links: SankeyLink[] + sourceY?: number + targetY?: number } // ─── Constants ────────────────────────────────────────────────────── -const COLUMN_COLORS = [ - '#FD5E0F', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', - '#EC4899', '#06B6D4', '#EF4444', '#84CC16', '#F97316', '#6366F1', -] - +const NODE_WIDTH = 30 +const NODE_GAP = 20 +const MIN_NODE_HEIGHT = 2 +const MAX_LINK_HEIGHT = 100 +const LINK_OPACITY = 0.3 +const LINK_HOVER_OPACITY = 0.6 const MAX_NODES_PER_STEP = 25 +const COLOR_PALETTE = [ + 'hsl(160, 45%, 40%)', 'hsl(220, 45%, 50%)', 'hsl(270, 40%, 50%)', + 'hsl(25, 50%, 50%)', 'hsl(340, 40%, 50%)', 'hsl(190, 45%, 45%)', + 'hsl(45, 45%, 50%)', 'hsl(0, 45%, 50%)', +] + // ─── Helpers ──────────────────────────────────────────────────────── +function pathFromId(id: string): string { + const idx = id.indexOf(':') + return idx >= 0 ? id.slice(idx + 1) : id +} + +function stepFromId(id: string): number { + const idx = id.indexOf(':') + return idx >= 0 ? parseInt(id.slice(0, idx), 10) : 0 +} + +function firstSegment(path: string): string { + const parts = path.split('/').filter(Boolean) + return parts.length > 0 ? `/${parts[0]}` : path +} + function smartLabel(path: string): string { if (path === '/' || path === '(other)') return path const segments = path.replace(/\/$/, '').split('/') @@ -47,147 +73,105 @@ function smartLabel(path: string): string { return `.../${segments[segments.length - 1]}` } -/** Extract the original path from a step-prefixed node id like "0:/blog" */ -function pathFromId(id: string): string { - const idx = id.indexOf(':') - return idx >= 0 ? id.slice(idx + 1) : id -} - -/** Extract the step index from a step-prefixed node id */ -function stepFromId(id: string): number { - const idx = id.indexOf(':') - return idx >= 0 ? parseInt(id.slice(0, idx), 10) : 0 -} - // ─── Data Transformation ──────────────────────────────────────────── -function buildSankeyData( +function buildData( transitions: PathTransition[], filterPath?: string, -): SankeyData { +): { nodes: SNode[]; links: SLink[] } { if (transitions.length === 0) return { nodes: [], links: [] } - // Group transitions by step and count sessions per path at each step + // Group transitions by step, count per path per step const stepPaths = new Map>() - for (const t of transitions) { - // from_path at step_index if (!stepPaths.has(t.step_index)) stepPaths.set(t.step_index, new Map()) const fromMap = stepPaths.get(t.step_index)! fromMap.set(t.from_path, (fromMap.get(t.from_path) ?? 0) + t.session_count) - // to_path at step_index + 1 const nextStep = t.step_index + 1 if (!stepPaths.has(nextStep)) stepPaths.set(nextStep, new Map()) const toMap = stepPaths.get(nextStep)! toMap.set(t.to_path, (toMap.get(t.to_path) ?? 0) + t.session_count) } - // For each step, keep top N paths, group rest into (other) - const topPathsPerStep = new Map>() - for (const [step, pathMap] of stepPaths) { - const sorted = Array.from(pathMap.entries()).sort((a, b) => b[1] - a[1]) - const kept = new Set(sorted.slice(0, MAX_NODES_PER_STEP).map(([p]) => p)) - topPathsPerStep.set(step, kept) + // Keep top N per step, rest → (other) + const topPaths = new Map>() + for (const [step, pm] of stepPaths) { + const sorted = Array.from(pm.entries()).sort((a, b) => b[1] - a[1]) + topPaths.set(step, new Set(sorted.slice(0, MAX_NODES_PER_STEP).map(([p]) => p))) } - // Build links with capping + // Build links const linkMap = new Map() for (const t of transitions) { - const fromStep = t.step_index - const toStep = t.step_index + 1 - const fromTop = topPathsPerStep.get(fromStep)! - const toTop = topPathsPerStep.get(toStep)! - - const fromPath = fromTop.has(t.from_path) ? t.from_path : '(other)' - const toPath = toTop.has(t.to_path) ? t.to_path : '(other)' - - // Skip self-links where both collapse to (other) - if (fromPath === '(other)' && toPath === '(other)') continue - - const sourceId = `${fromStep}:${fromPath}` - const targetId = `${toStep}:${toPath}` - const key = `${sourceId}|${targetId}` + const fromTop = topPaths.get(t.step_index)! + const toTop = topPaths.get(t.step_index + 1)! + const fp = fromTop.has(t.from_path) ? t.from_path : '(other)' + const tp = toTop.has(t.to_path) ? t.to_path : '(other)' + if (fp === '(other)' && tp === '(other)') continue + const src = `${t.step_index}:${fp}` + const tgt = `${t.step_index + 1}:${tp}` + const key = `${src}|${tgt}` linkMap.set(key, (linkMap.get(key) ?? 0) + t.session_count) } - let links: SankeyLink[] = Array.from(linkMap.entries()).map(([key, value]) => { - const [source, target] = key.split('|') - return { source, target, value } + let links: SLink[] = Array.from(linkMap.entries()).map(([k, v]) => { + const [source, target] = k.split('|') + return { source, target, value: v } }) - // Collect all node ids referenced by links + // Collect node IDs const nodeIdSet = new Set() - for (const link of links) { - nodeIdSet.add(link.source) - nodeIdSet.add(link.target) - } + for (const l of links) { nodeIdSet.add(l.source); nodeIdSet.add(l.target) } - let nodes: SankeyNode[] = Array.from(nodeIdSet).map((id) => ({ + let nodes: SNode[] = Array.from(nodeIdSet).map((id) => ({ id, - stepIndex: stepFromId(id), + name: pathFromId(id), + step: stepFromId(id), + height: 0, x: 0, y: 0, count: 0, + inLinks: [], outLinks: [], })) - // ─── Filter by path (BFS forward + backward) ──────────────────── + // Filter by path (BFS forward + backward) if (filterPath) { - const matchingNodeIds = nodes - .filter((n) => pathFromId(n.id) === filterPath) - .map((n) => n.id) + const matchIds = nodes.filter((n) => n.name === filterPath).map((n) => n.id) + if (matchIds.length === 0) return { nodes: [], links: [] } - if (matchingNodeIds.length === 0) return { nodes: [], links: [] } - - // Build adjacency - const forwardAdj = new Map>() - const backwardAdj = new Map>() - for (const link of links) { - if (!forwardAdj.has(link.source)) forwardAdj.set(link.source, new Set()) - forwardAdj.get(link.source)!.add(link.target) - if (!backwardAdj.has(link.target)) backwardAdj.set(link.target, new Set()) - backwardAdj.get(link.target)!.add(link.source) + const fwd = new Map>() + const bwd = new Map>() + for (const l of links) { + if (!fwd.has(l.source)) fwd.set(l.source, new Set()) + fwd.get(l.source)!.add(l.target) + if (!bwd.has(l.target)) bwd.set(l.target, new Set()) + bwd.get(l.target)!.add(l.source) } - const reachable = new Set(matchingNodeIds) - - // BFS forward - let queue = [...matchingNodeIds] + const reachable = new Set(matchIds) + let queue = [...matchIds] while (queue.length > 0) { const next: string[] = [] - for (const nodeId of queue) { - for (const neighbor of forwardAdj.get(nodeId) ?? []) { - if (!reachable.has(neighbor)) { - reachable.add(neighbor) - next.push(neighbor) - } + for (const id of queue) { + for (const nb of fwd.get(id) ?? []) { + if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) } + } + } + queue = next + } + queue = [...matchIds] + while (queue.length > 0) { + const next: string[] = [] + for (const id of queue) { + for (const nb of bwd.get(id) ?? []) { + if (!reachable.has(nb)) { reachable.add(nb); next.push(nb) } } } queue = next } - // BFS backward - queue = [...matchingNodeIds] - while (queue.length > 0) { - const next: string[] = [] - for (const nodeId of queue) { - for (const neighbor of backwardAdj.get(nodeId) ?? []) { - if (!reachable.has(neighbor)) { - reachable.add(neighbor) - next.push(neighbor) - } - } - } - queue = next - } - - links = links.filter( - (l) => reachable.has(l.source) && reachable.has(l.target), - ) - - const filteredNodeIds = new Set() - for (const link of links) { - filteredNodeIds.add(link.source) - filteredNodeIds.add(link.target) - } - nodes = nodes.filter((n) => filteredNodeIds.has(n.id)) + links = links.filter((l) => reachable.has(l.source) && reachable.has(l.target)) + const kept = new Set() + for (const l of links) { kept.add(l.source); kept.add(l.target) } + nodes = nodes.filter((n) => kept.has(n.id)) } return { nodes, links } @@ -202,43 +186,325 @@ export default function SankeyJourney({ }: SankeyJourneyProps) { const [filterPath, setFilterPath] = useState(null) const [isDark, setIsDark] = useState(false) + const svgRef = useRef(null) + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(900) - // Reactively detect dark mode via MutationObserver + // Detect dark mode useEffect(() => { const el = document.documentElement setIsDark(el.classList.contains('dark')) + const obs = new MutationObserver(() => setIsDark(el.classList.contains('dark'))) + obs.observe(el, { attributes: true, attributeFilter: ['class'] }) + return () => obs.disconnect() + }, []) - const observer = new MutationObserver(() => { - setIsDark(el.classList.contains('dark')) - }) - observer.observe(el, { attributes: true, attributeFilter: ['class'] }) - return () => observer.disconnect() + // Measure container + useEffect(() => { + const el = containerRef.current + if (!el) return + const measure = () => setContainerWidth(el.clientWidth) + measure() + const obs = new ResizeObserver(measure) + obs.observe(el) + return () => obs.disconnect() }, []) const data = useMemo( - () => buildSankeyData(transitions, filterPath ?? undefined), + () => buildData(transitions, filterPath ?? undefined), [transitions, filterPath], ) - const handleClick = useCallback( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (item: any) => { - if (!item.id || typeof item.id !== 'string') return // link click, ignore - const path = pathFromId(item.id) - if (path === '(other)') return - setFilterPath((prev) => (prev === path ? null : path)) - }, - [], - ) - - // Clear filter when data changes - const transitionsKey = transitions.length + '-' + depth - const [prevKey, setPrevKey] = useState(transitionsKey) - if (prevKey !== transitionsKey) { - setPrevKey(transitionsKey) + // Clear filter on data change + const transKey = transitions.length + '-' + depth + const [prevKey, setPrevKey] = useState(transKey) + if (prevKey !== transKey) { + setPrevKey(transKey) if (filterPath !== null) setFilterPath(null) } + const handleNodeClick = useCallback((path: string) => { + if (path === '(other)') return + setFilterPath((prev) => (prev === path ? null : path)) + }, []) + + // ─── D3 Rendering ────────────────────────────────────────────── + useEffect(() => { + if (!svgRef.current || data.nodes.length === 0) return + + const svg = d3.select(svgRef.current) + svg.selectAll('*').remove() + + const { nodes, links } = data + + const linkColor = isDark ? 'rgba(163,163,163,0.5)' : 'rgba(82,82,82,0.5)' + const textColor = isDark ? '#e5e5e5' : '#171717' + + // Wire up node ↔ link references + for (const n of nodes) { n.inLinks = []; n.outLinks = []; n.count = 0 } + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + for (const l of links) { + const src = nodeMap.get(l.source) + const tgt = nodeMap.get(l.target) + if (src) src.outLinks.push(l) + if (tgt) tgt.inLinks.push(l) + } + for (const n of nodes) { + const inVal = n.inLinks.reduce((s, l) => s + l.value, 0) + const outVal = n.outLinks.reduce((s, l) => s + l.value, 0) + n.count = n.step === 0 ? outVal : Math.max(inVal, outVal) + } + + // Calculate node heights (proportional to value) + const maxVal = d3.max(links, (l) => l.value) || 1 + const heightScale = d3.scaleLinear().domain([0, maxVal]).range([0, MAX_LINK_HEIGHT]) + for (const n of nodes) { + const inVal = n.inLinks.reduce((s, l) => s + l.value, 0) + const outVal = n.outLinks.reduce((s, l) => s + l.value, 0) + n.height = Math.max(heightScale(Math.max(inVal, outVal)), MIN_NODE_HEIGHT) + } + + // Group by step, determine layout + const byStep = d3.group(nodes, (n) => n.step) + const numSteps = byStep.size + const width = containerWidth + const stepWidth = width / numSteps + + // Calculate chart height from tallest column + const stepHeights = Array.from(byStep.values()).map( + (ns) => ns.reduce((s, n) => s + n.height, 0) + (ns.length - 1) * NODE_GAP, + ) + const height = Math.max(200, Math.max(...stepHeights) + 20) + + // Position nodes in columns, aligned from top + byStep.forEach((stepNodes, step) => { + let cy = 0 + for (const n of stepNodes) { + n.x = step * stepWidth + n.y = cy + n.height / 2 + cy += n.height + NODE_GAP + } + }) + + // Calculate link y-positions (stacked within each node) + for (const n of nodes) { + n.outLinks.sort((a, b) => b.value - a.value) + n.inLinks.sort((a, b) => b.value - a.value) + + let outY = n.y - n.height / 2 + for (const l of n.outLinks) { + const lh = heightScale(l.value) + l.sourceY = outY + lh / 2 + outY += lh + } + + let inY = n.y - n.height / 2 + for (const l of n.inLinks) { + const lh = heightScale(l.value) + l.targetY = inY + lh / 2 + inY += lh + } + } + + // Color by first path segment + const segCounts = new Map() + for (const n of nodes) { + const seg = firstSegment(n.name) + segCounts.set(seg, (segCounts.get(seg) ?? 0) + 1) + } + const segColors = new Map() + let ci = 0 + segCounts.forEach((count, seg) => { + if (count > 1) { segColors.set(seg, COLOR_PALETTE[ci % COLOR_PALETTE.length]); ci++ } + }) + const defaultColor = isDark ? 'hsl(0, 0%, 50%)' : 'hsl(0, 0%, 45%)' + const nodeColor = (n: SNode) => segColors.get(firstSegment(n.name)) ?? defaultColor + const linkSourceColor = (l: SLink) => { + const src = nodeMap.get(l.source) + return src ? nodeColor(src) : linkColor + } + + // Link path generator + const linkPath = (l: SLink) => { + const src = nodeMap.get(l.source) + const tgt = nodeMap.get(l.target) + if (!src || !tgt) return '' + const sy = l.sourceY ?? src.y + const ty = l.targetY ?? tgt.y + const sx = src.x + NODE_WIDTH + const tx = tgt.x + const gap = tx - sx + const c1x = sx + gap / 3 + const c2x = tx - gap / 3 + return `M ${sx},${sy} C ${c1x},${sy} ${c2x},${ty} ${tx},${ty}` + } + + svg.attr('width', width).attr('height', height) + const g = svg.append('g') + + // ── Draw links ──────────────────────────────────────── + g.selectAll('.link') + .data(links) + .join('path') + .attr('class', 'link') + .attr('d', linkPath) + .attr('fill', 'none') + .attr('stroke', (d) => linkSourceColor(d)) + .attr('stroke-width', (d) => heightScale(d.value)) + .attr('opacity', LINK_OPACITY) + .attr('data-source', (d) => d.source) + .attr('data-target', (d) => d.target) + .style('pointer-events', 'none') + + // ── Tooltip ─────────────────────────────────────────── + const tooltip = d3.select('body').append('div') + .style('position', 'absolute') + .style('visibility', 'hidden') + .style('background', isDark ? '#262626' : '#f5f5f5') + .style('border', `1px solid ${isDark ? '#404040' : '#d4d4d4'}`) + .style('border-radius', '8px') + .style('padding', '8px 12px') + .style('font-size', '12px') + .style('color', isDark ? '#fff' : '#171717') + .style('pointer-events', 'none') + .style('z-index', '9999') + .style('box-shadow', '0 4px 12px rgba(0,0,0,0.15)') + + // ── Draw nodes ──────────────────────────────────────── + const nodeGs = g.selectAll('.node') + .data(nodes) + .join('g') + .attr('class', 'node') + .attr('transform', (d) => `translate(${d.x},${d.y - d.height / 2})`) + .style('cursor', 'pointer') + + // Node bars + nodeGs.append('rect') + .attr('class', 'node-rect') + .attr('width', NODE_WIDTH) + .attr('height', (d) => d.height) + .attr('fill', (d) => nodeColor(d)) + .attr('rx', 2) + .attr('ry', 2) + + // Node labels + nodeGs.append('text') + .attr('class', 'node-text') + .attr('x', NODE_WIDTH + 6) + .attr('y', (d) => d.height / 2 + 4) + .text((d) => smartLabel(d.name)) + .attr('font-size', '12px') + .attr('fill', textColor) + .attr('text-anchor', 'start') + + // ── Hover: find all connected paths ─────────────────── + const findConnected = (startLink: SLink, dir: 'fwd' | 'bwd') => { + const result: SLink[] = [] + const visited = new Set() + const queue = [startLink] + while (queue.length > 0) { + const cur = queue.shift()! + const lid = `${cur.source}|${cur.target}` + if (visited.has(lid)) continue + visited.add(lid) + result.push(cur) + if (dir === 'fwd') { + const tgt = nodeMap.get(cur.target) + if (tgt) tgt.outLinks.forEach((l) => queue.push(l)) + } else { + const src = nodeMap.get(cur.source) + if (src) src.inLinks.forEach((l) => queue.push(l)) + } + } + return result + } + + const highlightPaths = (nodeId: string) => { + const connectedLinks: SLink[] = [] + const connectedNodes = new Set([nodeId]) + const directLinks = links.filter((l) => l.source === nodeId || l.target === nodeId) + for (const dl of directLinks) { + connectedLinks.push(dl, ...findConnected(dl, 'fwd'), ...findConnected(dl, 'bwd')) + } + const connectedLinkIds = new Set(connectedLinks.map((l) => `${l.source}|${l.target}`)) + connectedLinks.forEach((l) => { connectedNodes.add(l.source); connectedNodes.add(l.target) }) + + g.selectAll('.link') + .attr('opacity', function () { + const s = d3.select(this).attr('data-source') + const t = d3.select(this).attr('data-target') + return connectedLinkIds.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05 + }) + g.selectAll('.node-rect') + .attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.15) + g.selectAll('.node-text') + .attr('opacity', (d) => connectedNodes.has(d.id) ? 1 : 0.2) + } + + const resetHighlight = () => { + g.selectAll('.link').attr('opacity', LINK_OPACITY) + .attr('stroke', (d: unknown) => linkSourceColor(d as SLink)) + g.selectAll('.node-rect').attr('opacity', 1) + g.selectAll('.node-text').attr('opacity', 1) + tooltip.style('visibility', 'hidden') + } + + // Node hover + nodeGs + .on('mouseenter', function (event, d) { + tooltip.style('visibility', 'visible') + .html(`
${d.name}
${d.count.toLocaleString()} sessions
`) + .style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`) + highlightPaths(d.id) + }) + .on('mousemove', (event) => { + tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`) + }) + .on('mouseleave', resetHighlight) + .on('click', (_, d) => handleNodeClick(d.name)) + + // Link hit areas (wider invisible paths for easier hovering) + g.selectAll('.link-hit') + .data(links) + .join('path') + .attr('class', 'link-hit') + .attr('d', linkPath) + .attr('fill', 'none') + .attr('stroke', 'transparent') + .attr('stroke-width', (d) => Math.max(heightScale(d.value), 14)) + .attr('data-source', (d) => d.source) + .attr('data-target', (d) => d.target) + .style('cursor', 'pointer') + .on('mouseenter', function (event, d) { + const src = nodeMap.get(d.source) + const tgt = nodeMap.get(d.target) + tooltip.style('visibility', 'visible') + .html(`
${src?.name ?? '?'} → ${tgt?.name ?? '?'}
${d.value.toLocaleString()} sessions
`) + .style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`) + // Highlight this link's connected paths + const all = [d, ...findConnected(d, 'fwd'), ...findConnected(d, 'bwd')] + const lids = new Set(all.map((l) => `${l.source}|${l.target}`)) + const nids = new Set() + all.forEach((l) => { nids.add(l.source); nids.add(l.target) }) + g.selectAll('.link') + .attr('opacity', function () { + const s = d3.select(this).attr('data-source') + const t = d3.select(this).attr('data-target') + return lids.has(`${s}|${t}`) ? LINK_HOVER_OPACITY : 0.05 + }) + g.selectAll('.node-rect') + .attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.15) + g.selectAll('.node-text') + .attr('opacity', (nd) => nids.has(nd.id) ? 1 : 0.2) + }) + .on('mousemove', (event) => { + tooltip.style('top', `${event.pageY - 10}px`).style('left', `${event.pageX + 12}px`) + }) + .on('mouseleave', resetHighlight) + + return () => { tooltip.remove() } + }, [data, containerWidth, isDark, handleNodeClick]) + // ─── Empty state ──────────────────────────────────────────────── if (!transitions.length || data.nodes.length === 0) { return ( @@ -256,34 +522,10 @@ export default function SankeyJourney({ ) } - const labelColor = isDark ? '#a3a3a3' : '#525252' - - // * Scale height based on max nodes in any step so blocks aren't too compressed - const stepCounts = new Map() - for (const n of data.nodes) { - stepCounts.set(n.stepIndex, (stepCounts.get(n.stepIndex) ?? 0) + 1) - } - const maxNodesInStep = Math.max(1, ...Array.from(stepCounts.values())) - const chartHeight = Math.max(400, Math.min(700, maxNodesInStep * 45)) - const containerRef = useRef(null) - const [chartWidth, setChartWidth] = useState(800) - - // * Measure container width and resize chart to fit without horizontal scroll - useEffect(() => { - const el = containerRef.current - if (!el) return - const measure = () => setChartWidth(el.clientWidth) - measure() - const observer = new ResizeObserver(measure) - observer.observe(el) - return () => observer.disconnect() - }, []) - return (
- {/* Filter reset bar */} {filterPath && ( -
+
Showing flows through{' '} @@ -301,71 +543,8 @@ export default function SankeyJourney({
)} -
-
- - data={data} - width={chartWidth} - height={chartHeight} - margin={{ top: 8, right: 16, bottom: 8, left: 16 }} - align="justify" - sort="descending" - colors={(node) => - COLUMN_COLORS[node.stepIndex % COLUMN_COLORS.length] - } - nodeThickness={100} - nodeSpacing={4} - nodeInnerPadding={2} - nodeBorderWidth={0} - nodeBorderRadius={4} - nodeOpacity={0.9} - nodeHoverOpacity={1} - nodeHoverOthersOpacity={0.3} - linkOpacity={0.12} - linkHoverOpacity={0.4} - linkHoverOthersOpacity={0.02} - linkContract={2} - enableLinkGradient - enableLabels - label={(node) => smartLabel(pathFromId(node.id))} - labelPosition="inside" - labelPadding={8} - labelTextColor="#ffffff" - isInteractive - onClick={handleClick} - nodeTooltip={({ node }) => ( -
-
- {pathFromId(node.id)} -
-
- Step {node.stepIndex + 1} ·{' '} - {node.value.toLocaleString()} sessions -
-
- )} - linkTooltip={({ link }) => ( -
-
- {pathFromId(link.source.id)} →{' '} - {pathFromId(link.target.id)} -
-
- {link.value.toLocaleString()} sessions -
-
- )} - theme={{ - tooltip: { - container: { - background: 'transparent', - boxShadow: 'none', - padding: 0, - }, - }, - }} - /> -
+
+
) diff --git a/package-lock.json b/package-lock.json index 55577fe..df9c23b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,16 @@ "dependencies": { "@ciphera-net/ui": "^0.2.7", "@ducanh2912/next-pwa": "^10.2.9", - "@nivo/sankey": "^0.99.0", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@tanstack/react-virtual": "^3.13.21", + "@types/d3": "^7.4.3", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", + "d3": "^7.9.0", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", @@ -3270,174 +3271,6 @@ "node": ">= 10" } }, - "node_modules/@nivo/colors": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", - "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/theming": "0.99.0", - "@types/d3-color": "^3.0.0", - "@types/d3-scale": "^4.0.8", - "@types/d3-scale-chromatic": "^3.0.0", - "d3-color": "^3.1.0", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.0.0", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/core": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", - "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", - "license": "MIT", - "dependencies": { - "@nivo/theming": "0.99.0", - "@nivo/tooltip": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "@types/d3-shape": "^3.1.6", - "d3-color": "^3.1.0", - "d3-format": "^1.4.4", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.0.0", - "d3-shape": "^3.2.0", - "d3-time-format": "^3.0.0", - "lodash": "^4.17.21", - "react-virtualized-auto-sizer": "^1.0.26", - "use-debounce": "^10.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nivo/donate" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/core/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/@nivo/core/node_modules/d3-format": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", - "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@nivo/core/node_modules/d3-time": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", - "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "2" - } - }, - "node_modules/@nivo/core/node_modules/d3-time-format": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", - "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-time": "1 - 2" - } - }, - "node_modules/@nivo/core/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, - "node_modules/@nivo/legends": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", - "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", - "license": "MIT", - "dependencies": { - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@types/d3-scale": "^4.0.8", - "d3-scale": "^4.0.2" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/sankey": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/sankey/-/sankey-0.99.0.tgz", - "integrity": "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw==", - "license": "MIT", - "dependencies": { - "@nivo/colors": "0.99.0", - "@nivo/core": "0.99.0", - "@nivo/legends": "0.99.0", - "@nivo/text": "0.99.0", - "@nivo/theming": "0.99.0", - "@nivo/tooltip": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", - "@types/d3-sankey": "^0.11.2", - "@types/d3-shape": "^3.1.6", - "d3-sankey": "^0.12.3", - "d3-shape": "^3.2.0", - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/text": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", - "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/theming": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/theming": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", - "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, - "node_modules/@nivo/tooltip": { - "version": "0.99.0", - "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", - "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", - "license": "MIT", - "dependencies": { - "@nivo/core": "0.99.0", - "@nivo/theming": "0.99.0", - "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" - }, - "peerDependencies": { - "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4994,78 +4827,6 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@react-spring/animated": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", - "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", - "license": "MIT", - "dependencies": { - "@react-spring/shared": "~10.0.3", - "@react-spring/types": "~10.0.3" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/core": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", - "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", - "license": "MIT", - "dependencies": { - "@react-spring/animated": "~10.0.3", - "@react-spring/shared": "~10.0.3", - "@react-spring/types": "~10.0.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-spring/donate" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/rafz": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", - "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", - "license": "MIT" - }, - "node_modules/@react-spring/shared": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", - "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", - "license": "MIT", - "dependencies": { - "@react-spring/rafz": "~10.0.3", - "@react-spring/types": "~10.0.3" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@react-spring/types": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", - "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", - "license": "MIT" - }, - "node_modules/@react-spring/web": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", - "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", - "license": "MIT", - "dependencies": { - "@react-spring/animated": "~10.0.3", - "@react-spring/core": "~10.0.3", - "@react-spring/shared": "~10.0.3", - "@react-spring/types": "~10.0.3" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -5833,24 +5594,159 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "license": "MIT" }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -5866,29 +5762,23 @@ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", "license": "MIT" }, - "node_modules/@types/d3-sankey": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.11.2.tgz", - "integrity": "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ==", - "license": "MIT", - "dependencies": { - "@types/d3-shape": "^1" - } - }, - "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", - "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", "license": "MIT" }, - "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", - "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "^1" - } + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", @@ -5905,6 +5795,12 @@ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", "license": "MIT" }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -5920,12 +5816,37 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -5977,6 +5898,12 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -8078,6 +8005,47 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -8090,6 +8058,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -8099,6 +8104,121 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -8108,6 +8228,27 @@ "node": ">=12" } }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -8129,46 +8270,33 @@ "node": ">=12" } }, - "node_modules/d3-sankey": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", - "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1 - 2", - "d3-shape": "^1.2.0" + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/d3-sankey/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "license": "BSD-3-Clause", - "dependencies": { - "internmap": "^1.0.0" + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/d3-sankey/node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" - }, - "node_modules/d3-sankey/node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/d3-sankey/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "license": "ISC" - }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -8198,6 +8326,16 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -8243,6 +8381,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -8446,6 +8619,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -10074,6 +10256,18 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -13017,16 +13211,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/react-virtualized-auto-sizer": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", - "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", - "license": "MIT", - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -13306,6 +13490,12 @@ "node": ">= 0.8.15" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", @@ -13345,6 +13535,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -13417,6 +13613,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -14977,18 +15179,6 @@ } } }, - "node_modules/use-debounce": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz", - "integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==", - "license": "MIT", - "engines": { - "node": ">= 16.0.0" - }, - "peerDependencies": { - "react": "*" - } - }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", @@ -15087,15 +15277,6 @@ "d3-timer": "^3.0.1" } }, - "node_modules/victory-vendor/node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index bd8319e..0c5d64a 100644 --- a/package.json +++ b/package.json @@ -14,15 +14,16 @@ "dependencies": { "@ciphera-net/ui": "^0.2.7", "@ducanh2912/next-pwa": "^10.2.9", - "@nivo/sankey": "^0.99.0", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "@tanstack/react-virtual": "^3.13.21", + "@types/d3": "^7.4.3", "class-variance-authority": "^0.7.1", "cobe": "^0.6.5", "country-flag-icons": "^1.6.4", + "d3": "^7.9.0", "d3-scale": "^4.0.2", "framer-motion": "^12.23.26", "html-to-image": "^1.11.13", -- 2.49.1 From 52427fea93cb4afaf4e027bbb9777a2cd756cab9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 22:02:29 +0100 Subject: [PATCH 023/428] fix: change journey depth default to 4, max to 5 --- app/sites/[id]/journeys/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index f1ddb9d..ec3c042 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -16,7 +16,7 @@ import { useJourneyEntryPoints, } from '@/lib/swr/dashboard' -const DEFAULT_DEPTH = 10 +const DEFAULT_DEPTH = 4 function getThisWeekRange(): { start: string; end: string } { const today = new Date() @@ -148,12 +148,12 @@ export default function JourneysPage() { {depth} steps deep - 10 steps + 5 steps
setDepth(parseInt(e.target.value))} -- 2.49.1 From b16f01bd7f03c9a8cfcb1374db0bc9e47c257288 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 22:08:14 +0100 Subject: [PATCH 024/428] fix: rename Step 1 to Entry in columns view, max depth to 6 --- app/sites/[id]/journeys/page.tsx | 4 ++-- components/journeys/ColumnJourney.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index ec3c042..10d66e6 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -148,12 +148,12 @@ export default function JourneysPage() { {depth} steps deep - 5 steps + 6 steps
setDepth(parseInt(e.target.value))} diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index 1646450..c03ca18 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -163,7 +163,7 @@ function ColumnHeader({ return (
- Step {column.index + 1} + {column.index === 0 ? 'Entry' : `Step ${column.index + 1}`}
-- 2.49.1 From 1ba6f6609dbaea2c9337ec7c81ec88a342ddc125 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 22:27:11 +0100 Subject: [PATCH 025/428] fix: step numbering starts at 1 after Entry column --- components/journeys/ColumnJourney.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/journeys/ColumnJourney.tsx b/components/journeys/ColumnJourney.tsx index c03ca18..fe6e706 100644 --- a/components/journeys/ColumnJourney.tsx +++ b/components/journeys/ColumnJourney.tsx @@ -163,7 +163,7 @@ function ColumnHeader({ return (
- {column.index === 0 ? 'Entry' : `Step ${column.index + 1}`} + {column.index === 0 ? 'Entry' : `Step ${column.index}`}
-- 2.49.1 From 01222bf0a90af94a2bd3d9bcbc9fe76b0689b8b7 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 16 Mar 2026 23:14:07 +0100 Subject: [PATCH 026/428] =?UTF-8?q?fix:=20bump=20dark=20mode=20inline=20ba?= =?UTF-8?q?r=20opacity=20from=2025%=20to=2040%=20=E2=80=94=20less=20brown,?= =?UTF-8?q?=20more=20orange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/dashboard/Campaigns.tsx | 2 +- components/dashboard/ContentStats.tsx | 2 +- components/dashboard/Locations.tsx | 2 +- components/dashboard/TechSpecs.tsx | 2 +- components/dashboard/TopReferrers.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/dashboard/Campaigns.tsx b/components/dashboard/Campaigns.tsx index 771e58a..1c7ed31 100644 --- a/components/dashboard/Campaigns.tsx +++ b/components/dashboard/Campaigns.tsx @@ -164,7 +164,7 @@ export default function Campaigns({ siteId, dateRange, filters, onFilter }: Camp className={`relative flex items-center justify-between py-1.5 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/dashboard/ContentStats.tsx b/components/dashboard/ContentStats.tsx index 6436398..f828889 100644 --- a/components/dashboard/ContentStats.tsx +++ b/components/dashboard/ContentStats.tsx @@ -159,7 +159,7 @@ export default function ContentStats({ topPages, entryPages, exitPages, domain, className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/dashboard/Locations.tsx b/components/dashboard/Locations.tsx index ddb9895..3fb9374 100644 --- a/components/dashboard/Locations.tsx +++ b/components/dashboard/Locations.tsx @@ -306,7 +306,7 @@ export default function Locations({ countries, cities, regions, geoDataLevel = ' className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/dashboard/TechSpecs.tsx b/components/dashboard/TechSpecs.tsx index 51f9ec8..bd39109 100644 --- a/components/dashboard/TechSpecs.tsx +++ b/components/dashboard/TechSpecs.tsx @@ -191,7 +191,7 @@ export default function TechSpecs({ browsers, os, devices, screenResolutions, co className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${canFilter ? ' cursor-pointer' : ''}`} >
diff --git a/components/dashboard/TopReferrers.tsx b/components/dashboard/TopReferrers.tsx index 1f87352..04f1e19 100644 --- a/components/dashboard/TopReferrers.tsx +++ b/components/dashboard/TopReferrers.tsx @@ -123,7 +123,7 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI className={`relative flex items-center justify-between h-9 group hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50 rounded-lg px-2 -mx-2 transition-colors${onFilter ? ' cursor-pointer' : ''}`} >
-- 2.49.1 From d45d39aa607014974e886f4b2dc4ee5a356eafa8 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 10:17:21 +0100 Subject: [PATCH 027/428] feat: add client-side headless browser detection Skip events from headless Chrome (empty plugins, missing chrome runtime), hidden browsers (zero outer dimensions), and known bot viewports (1024x1024). --- public/script.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/public/script.js b/public/script.js index f66f4c8..9dfc800 100644 --- a/public/script.js +++ b/public/script.js @@ -18,6 +18,21 @@ return; } + // * Skip likely bots: headless Chrome fingerprints and known bot viewports + var isChrome = /Chrome/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent); + if ( + // * Headless Chrome has zero plugins (real Chrome always has at least 1; Firefox excluded — it legitimately reports 0) + (isChrome && navigator.plugins && navigator.plugins.length === 0) || + // * Headless Chrome lacks the chrome runtime object + (isChrome && !window.chrome) || + // * No outer window dimensions — headless or hidden browser + (window.outerWidth === 0 || window.outerHeight === 0) || + // * Default headless viewport: exactly 1024x1024 (no real monitor uses this) + (window.innerWidth === 1024 && window.innerHeight === 1024) + ) { + return; + } + // * Get domain from script tag const script = document.currentScript || document.querySelector('script[data-domain]'); if (!script || !script.getAttribute('data-domain')) { -- 2.49.1 From ebd25770b4d706d740604066212e803055a8f60d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 10:19:29 +0100 Subject: [PATCH 028/428] revert: remove client-side bot detection from tracking script Server-side heuristic scoring already catches these patterns via IsSuspiciousEvent. Client-side checks are trivially bypassable (script is public) and add payload weight for all real users. --- public/script.js | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/public/script.js b/public/script.js index 9dfc800..e864977 100644 --- a/public/script.js +++ b/public/script.js @@ -18,20 +18,6 @@ return; } - // * Skip likely bots: headless Chrome fingerprints and known bot viewports - var isChrome = /Chrome/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent); - if ( - // * Headless Chrome has zero plugins (real Chrome always has at least 1; Firefox excluded — it legitimately reports 0) - (isChrome && navigator.plugins && navigator.plugins.length === 0) || - // * Headless Chrome lacks the chrome runtime object - (isChrome && !window.chrome) || - // * No outer window dimensions — headless or hidden browser - (window.outerWidth === 0 || window.outerHeight === 0) || - // * Default headless viewport: exactly 1024x1024 (no real monitor uses this) - (window.innerWidth === 1024 && window.innerHeight === 1024) - ) { - return; - } // * Get domain from script tag const script = document.currentScript || document.querySelector('script[data-domain]'); -- 2.49.1 From e7ebe2a923464441c6680b3f5cde8a43b380586f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 10:20:52 +0100 Subject: [PATCH 029/428] refactor: remove client-side 0x0 screen check, handled server-side IsSuspiciousEvent already scores 0x0 screens as +5 (bot threshold). Keeping the check client-side hides bot traffic from analysis and is trivially bypassable. --- public/script.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/public/script.js b/public/script.js index e864977..68c70b3 100644 --- a/public/script.js +++ b/public/script.js @@ -284,11 +284,6 @@ height: window.innerHeight || window.screen.height, }; - // * Skip bots with no screen dimensions (0x0) - if (screenSize.width === 0 && screenSize.height === 0) { - return; - } - const payload = { domain: domain, path: path, -- 2.49.1 From 109aca62c0fbb6030a5248d1e9edaf075440924c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 10:24:44 +0100 Subject: [PATCH 030/428] docs: add bot filtering and script improvements to changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bbcc5a..2cf374b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Your analytics data will be cleaner with fewer fake visitors inflating your numbers. +- **Leaner tracking script.** Bot detection checks that belonged on the server have been moved there, making the tracking script smaller and faster for your real visitors. + +### Improved + - **Actionable empty states.** When a dashboard section has no data yet, you now get a direct action — like "Install tracking script" or "Build a UTM URL" — instead of just passive text. Gets you set up faster. - **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value. - **Inline bar charts on dashboard lists.** Pages, referrers, locations, technology, and campaigns now show subtle proportional bars behind each row, making it easier to compare values at a glance without reading numbers. -- 2.49.1 From 81e2e8bd6ca024cbc97a410cace6c528fe9b2052 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 11:03:17 +0100 Subject: [PATCH 031/428] chore: consolidate unreleased changelog entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge duplicate section headers, remove duplicate visit duration entry, trim granular sub-features into parent entries, and reorder sections to follow Keep a Changelog convention (Added → Improved → Removed → Fixed). --- CHANGELOG.md | 66 +++++++++++++--------------------------------------- 1 file changed, 16 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf374b..7ba9082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,75 +6,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] -### Improved +### Added -- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Your analytics data will be cleaner with fewer fake visitors inflating your numbers. -- **Leaner tracking script.** Bot detection checks that belonged on the server have been moved there, making the tracking script smaller and faster for your real visitors. +- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time. +- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time. +- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free. +- **Free plan limited to 1 site.** Free accounts are now limited to a single site. If you need more, you can upgrade to Solo or above from the Pricing page. ### Improved +- **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors. - **Actionable empty states.** When a dashboard section has no data yet, you now get a direct action — like "Install tracking script" or "Build a UTM URL" — instead of just passive text. Gets you set up faster. - **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value. - **Inline bar charts on dashboard lists.** Pages, referrers, locations, technology, and campaigns now show subtle proportional bars behind each row, making it easier to compare values at a glance without reading numbers. -- **Redesigned Top Paths.** The Top Paths section on the Journeys page has been completely rebuilt — from bulky cards to a clean, compact list with inline bars that matches the rest of Pulse. Long path sequences are truncated so they stay readable. -- **Redesigned Journeys page.** The depth slider now matches the rest of the UI and goes up to 10 steps. Controls are integrated into the chart card for a cleaner layout. -- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s. +- **Redesigned Journeys page.** The Journeys page has been rebuilt — the depth slider now matches the rest of the UI and goes up to 10 steps, controls are integrated into the chart card, and Top Paths uses a clean compact list with inline bars instead of bulky cards. +- **More reliable visit duration tracking.** Visit duration was silently dropping to 0s for visitors who only viewed one page — especially on mobile or when closing a tab quickly. The tracking script now captures time-on-page more reliably across all browsers, and sessions where duration couldn't be measured are excluded from the average instead of counting as 0s. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate. - **More accurate rage click detection.** Rage clicks no longer fire when you triple-click to select text on a page. Previously, selecting a paragraph (a normal 3-click action) was being counted as a rage click, which inflated frustration metrics. Only genuinely frustrated rapid clicking is tracked now. - **Fresher CDN data.** BunnyCDN statistics now refresh every 3 hours instead of once a day, so your CDN tab shows much more current bandwidth, request, and cache data. - **More accurate dead click detection.** Dead clicks were being reported on elements that actually worked — like close buttons on cart drawers, modal dismiss buttons, and page content areas. Three fixes make dead clicks much more reliable: - Buttons that trigger changes elsewhere on the page (closing a drawer, opening a modal) are no longer flagged as dead. - Page content areas that aren't actually clickable (like `
` containers) are no longer treated as interactive elements. - Single-page app navigations are now properly detected, so links that use client-side routing aren't mistakenly reported as broken. +- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 2–3 sessions following the same path before showing any data. It now shows all navigation flows immediately, so you can see how visitors move through your site from day one. +- **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more. +- **Sites now show their verification status.** Each site on your dashboard now displays either a green "Active" badge (if verified) or an amber "Unverified" badge. The Settings page also shows a green confirmation bar once verified. When you verify your tracking script installation, the status is saved permanently — no more showing "Active" for sites that haven't been set up yet. +- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Trailing slashes are also normalized — `/about/` and `/about` count as the same page. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean. +- **Easier to hover country dots on the map.** The orange location markers on the world map are now much easier to interact with — you no longer need pixel-perfect aim to see the tooltip. +- **Smoother chart curves and filled area.** The dashboard chart line now flows with natural curves instead of sharp flat tops at peaks. The area beneath the line is filled with a soft transparent orange gradient that fades toward the bottom, making trends easier to read at a glance. +- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Behavior, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data. +- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait. ### Removed - **Performance insights removed.** The Performance tab, Core Web Vitals tracking (LCP, CLS, INP), and the "Enable performance insights" toggle in Settings have been removed. The tracking script no longer collects Web Vitals data. Visit duration tracking continues to work as before. -### Added - -- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time. -- **Smart pull zone matching.** When connecting BunnyCDN, Pulse automatically filters your pull zones to only show the ones that match your tracked site's domain — so you can't accidentally connect the wrong pull zone. - -- **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time. -- **Integrations tab in Settings.** A new "Integrations" section in your site settings is where you connect and manage external services. Google Search Console is the first integration available — more will follow. -- **Search performance on your dashboard.** When Google Search Console is connected, a new "Search" panel appears on your main dashboard alongside Campaigns — showing your total clicks, impressions, and average position at a glance, plus your top 5 search queries. Click "View all" to dive deeper. -- **Clicks & Impressions trend chart.** The Search tab now includes a chart showing how your clicks and impressions change over time, so you can spot trends and correlate them with content changes. -- **Top query position cards.** Five compact cards at the top of the Search tab show your best-performing queries with their average ranking position and click count. -- **New queries indicator.** A green badge on the Search tab tells you how many new search queries appeared this period compared to the last — so you can see your search footprint growing. - ### Fixed -- **BunnyCDN logo now displays correctly.** The BunnyCDN integration card in Settings previously showed a generic globe icon. It now shows the proper BunnyCDN bunny logo. - **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting. - - **No more "Site not found" when switching back to Pulse.** If you left Pulse in the background and came back, you could see a wall of errors and a blank page. This happened because the browser fired several requests at once when the tab regained focus, and if any failed, they all retried repeatedly — flooding the connection and making it worse. Failed requests now back off gracefully instead of retrying in a loop. - -### Improved - -- **Visit duration now works for single-page sessions.** Previously, if a visitor viewed only one page and left, the visit duration showed as "0s" because there was no second pageview to measure against. Pulse now tracks how long you actually spent on the page and reports real durations — even for single-page visits. This makes the Visit Duration metric, Journeys, and Top Paths much more accurate. -- **Journeys page now shows data on low-traffic sites.** The Journeys page previously required at least 2–3 sessions following the same path before showing any data. It now shows all navigation flows immediately, so you can see how visitors move through your site from day one. -- **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more. - -### Added - -- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free. -- **Free plan limited to 1 site.** Free accounts are now limited to a single site. If you need more, you can upgrade to Solo or above from the Pricing page. - -### Improved - -- **Sites now show their verification status.** Each site on your dashboard now displays either a green "Active" badge (if verified) or an amber "Unverified" badge. When you verify your tracking script installation, the status is saved permanently — no more showing "Active" for sites that haven't been set up yet. -- **Verification status visible in Settings too.** Once your tracking script is verified, the Settings page shows a green confirmation bar instead of the verify button — so you can tell at a glance that everything is working. A "Re-verify" link is still there if you ever need to check again. -- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean without us having to chase down every new parameter format. -- **Easier to hover country dots on the map.** The orange location markers on the world map are now much easier to interact with — you no longer need pixel-perfect aim to see the tooltip. -- **Smoother chart curves and filled area.** The dashboard chart line now flows with natural curves instead of sharp flat tops at peaks. The area beneath the line is filled with a soft transparent orange gradient that fades toward the bottom, making trends easier to read at a glance. -- **Refreshed chart background.** The dashboard chart now has subtle horizontal lines instead of the old dotted background, giving the chart area a cleaner look. -- **Smoother loading transitions.** When your data finishes loading, the page now fades in smoothly instead of appearing all at once. This applies across Dashboard, Journeys, Funnels, Behavior, Uptime, Settings, Notifications, and shared dashboards. If your data was already cached from a previous visit, it still loads instantly with no animation — the fade only kicks in when you're actually waiting for fresh data. -- **Faster tab switching across the board.** Switching between Settings, Funnels, Uptime, and other tabs now shows your data instantly instead of flashing a loading skeleton every time. Previously visited tabs remember their data and show it right away, while quietly refreshing in the background so you always see the latest numbers without the wait. -- **Smoother loading on the Journeys page.** The Journeys tab now shows a polished skeleton placeholder while data loads, matching the loading experience on other tabs. -- **Consistent chart colors.** All dashboard charts — Unique Visitors, Total Pageviews, Bounce Rate, and Visit Duration — now use the same brand orange color for a cleaner, more cohesive look. - -### Fixed - - **No more random errors when switching tabs.** Navigating between Dashboard, Funnels, Uptime, and Settings no longer shows "Invalid credentials", "Something went wrong", or "Site not found" errors. This was caused by a timing issue when your login session refreshed in the background while multiple pages were loading at the same time — all those requests now wait for the refresh to finish and retry cleanly. - **More accurate pageview counts.** Refreshing a page no longer inflates your pageview numbers. The tracking script now detects when the same page is loaded again within a few seconds and skips the duplicate, so metrics like total pageviews, pages per session, and visit duration reflect real navigation instead of reload habits. - **Self-referrals no longer pollute your traffic sources.** Internal navigation within your own site (e.g. clicking from your homepage to your about page) no longer shows your own domain as a referrer. Only external traffic sources appear in your Referrers panel now. @@ -82,11 +51,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Browser back/forward no longer double-counts pageviews.** Pressing the back or forward button could occasionally register two pageviews instead of one. The tracking script now correctly deduplicates these navigations. - **Preloaded pages no longer count as visits.** Modern browsers sometimes preload pages in the background before you actually visit them. These ghost visits no longer inflate your pageview counts — only pages the visitor actually sees are tracked. - **Marketing parameters no longer fragment your pages.** Pages like `/about?utm_source=google` and `/about?utm_campaign=spring` now correctly show as just `/about` in your Top Pages. UTM tags, Facebook click IDs, Google click IDs, and other tracking parameters are stripped from the page path so all visits to the same page are grouped together. -- **Trailing slashes no longer split pages.** `/about/` and `/about` now count as the same page instead of appearing as separate entries in your analytics. - **Traffic sources are no longer over-counted.** When a visitor arrived from Facebook (or any external source) and browsed multiple pages, every page was credited to Facebook instead of just the first. Now only the landing page shows the referrer, giving you accurate traffic source numbers. - **UTM attribution now works correctly.** Visitors arriving via campaign links (e.g. from Facebook Ads, Google Ads, or email campaigns) now have their traffic source, medium, and campaign properly recorded. Previously, this data was accidentally lost before it reached the server. -- **More ad tracking clutter removed from page paths.** Facebook ad parameters and click IDs from various platforms are cleaned from your page URLs so your Top Pages stay tidy. -- **Better bot detection.** Automated browsers (used by scrapers and testing tools) and bots with no screen dimensions are now filtered out before they can send events, keeping your visitor counts cleaner. - **Outbound links and file downloads now show the URL.** Previously you could only see how many outbound clicks or downloads happened. Now you can see exactly which external links visitors clicked and which files they downloaded. - **Dead click detection no longer triggers on form fields.** Clicking on a text input, dropdown, or text area to interact with it is normal — it no longer gets flagged as a dead click. -- 2.49.1 From c833a759f4ddc8a791236df95c99a2de5565efe2 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 12:28:23 +0100 Subject: [PATCH 032/428] refactor: slim tracking script, move logic server-side --- public/script.js | 121 +++++------------------------------------------ 1 file changed, 13 insertions(+), 108 deletions(-) diff --git a/public/script.js b/public/script.js index 68c70b3..21632f6 100644 --- a/public/script.js +++ b/public/script.js @@ -189,40 +189,17 @@ return cachedSessionId; } - // * Normalize path: strip trailing slash and all query params except UTM/attribution. - // * Allowlist approach — only UTM params pass through because the backend extracts - // * them for attribution before cleaning the stored path. Everything else (cache-busters, - // * ad click IDs, filter params, etc.) is stripped to prevent path fragmentation. - var KEEP_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'source', 'ref']; + // * Normalize path: strip trailing slash, return pathname only. + // * UTM extraction and query handling moved server-side. function cleanPath() { var pathname = window.location.pathname; // * Strip trailing slash (but keep root /) if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') { pathname = pathname.slice(0, -1); } - // * Only keep allowlisted params, strip everything else - var search = window.location.search; - if (search) { - try { - var params = new URLSearchParams(search); - var kept = new URLSearchParams(); - for (var i = 0; i < KEEP_PARAMS.length; i++) { - if (params.has(KEEP_PARAMS[i])) { - kept.set(KEEP_PARAMS[i], params.get(KEEP_PARAMS[i])); - } - } - var remaining = kept.toString(); - if (remaining) pathname += '?' + remaining; - } catch (e) { - // * URLSearchParams not supported — send path without query - } - } return pathname; } - // * SPA referrer: only attribute external referrer to the landing page - var firstPageviewSent = false; - // * Refresh dedup: skip pageview if the same path was tracked within 5 seconds // * Prevents inflated pageview counts from F5/refresh while allowing genuine revisits var REFRESH_DEDUP_WINDOW = 5000; @@ -263,22 +240,7 @@ currentEventId = null; pageStartTime = 0; - // * Only send external referrer on the first pageview (landing page). - // * SPA navigations keep document.referrer stale, so clear it after first hit - // * to avoid inflating traffic source attribution. - var referrer = ''; - if (!firstPageviewSent) { - var rawReferrer = document.referrer || ''; - if (rawReferrer) { - try { - var refHost = new URL(rawReferrer).hostname.replace(/^www\./, ''); - var siteHost = domain.replace(/^www\./, ''); - if (refHost !== siteHost) referrer = rawReferrer; - } catch (e) { - referrer = rawReferrer; - } - } - } + const screenSize = { width: window.innerWidth || window.screen.width, height: window.innerHeight || window.screen.height, @@ -286,8 +248,9 @@ const payload = { domain: domain, - path: path, - referrer: referrer, + url: location.href, + title: document.title, + referrer: document.referrer || '', screen: screenSize, session_id: getSessionId(), }; @@ -303,7 +266,6 @@ }).then(res => res.json()) .then(data => { recordPageview(path); - firstPageviewSent = true; if (data && data.id) { currentEventId = data.id; pageStartTime = Date.now(); @@ -331,8 +293,6 @@ if (url !== lastUrl) { lastUrl = url; trackPageview(); - // * Check for 404 after SPA navigation (deferred so title updates first) - setTimeout(check404, 100); // * Reset scroll depth tracking for the new page if (trackScroll) scrollFired = {}; } @@ -349,58 +309,23 @@ if (url === lastUrl) return; lastUrl = url; trackPageview(); - setTimeout(check404, 100); if (trackScroll) scrollFired = {}; }); - // * Custom events / goals: validate event name (letters, numbers, underscores only; max 64 chars) - var EVENT_NAME_MAX = 64; - var EVENT_NAME_REGEX = /^[a-zA-Z0-9_]+$/; - + // * Custom events / goals function trackCustomEvent(eventName, props) { if (typeof eventName !== 'string' || !eventName.trim()) return; - var name = eventName.trim().toLowerCase(); - if (name.length > EVENT_NAME_MAX || !EVENT_NAME_REGEX.test(name)) { - if (typeof console !== 'undefined' && console.warn) { - console.warn('Pulse: event name must contain only letters, numbers, and underscores (max ' + EVENT_NAME_MAX + ' chars).'); - } - return; - } - var path = cleanPath(); - // * Custom events use same referrer logic: only on first pageview, empty after - var referrer = ''; - if (!firstPageviewSent) { - var rawRef = document.referrer || ''; - if (rawRef) { - try { - var rh = new URL(rawRef).hostname.replace(/^www\./, ''); - var sh = domain.replace(/^www\./, ''); - if (rh !== sh) referrer = rawRef; - } catch (e) { referrer = rawRef; } - } - } - var screenSize = { width: window.innerWidth || 0, height: window.innerHeight || 0 }; var payload = { domain: domain, - path: path, - referrer: referrer, - screen: screenSize, + url: location.href, + title: document.title, + referrer: document.referrer || '', + screen: { width: window.innerWidth || 0, height: window.innerHeight || 0 }, session_id: getSessionId(), - name: name, + name: eventName.trim().toLowerCase(), }; - // * Attach custom properties if provided (max 30 props, key max 200 chars, value max 2000 chars) if (props && typeof props === 'object' && !Array.isArray(props)) { - var sanitized = {}; - var count = 0; - for (var key in props) { - if (!props.hasOwnProperty(key)) continue; - if (count >= 30) break; - var k = String(key).substring(0, 200); - var v = String(props[key]).substring(0, 2000); - sanitized[k] = v; - count++; - } - if (count > 0) payload.props = sanitized; + payload.props = props; } fetch(apiUrl + '/api/v1/events', { method: 'POST', @@ -415,26 +340,6 @@ window.pulse.track = trackCustomEvent; window.pulse.cleanPath = cleanPath; - // * Auto-track 404 error pages (on by default) - // * Detects pages where document.title contains "404" or "not found" - // * Opt-out: add data-no-404 to the script tag - var track404 = !script.hasAttribute('data-no-404'); - var sent404ForUrl = ''; - - function check404() { - if (!track404) return; - // * Only fire once per URL - var currentUrl = location.href; - if (sent404ForUrl === currentUrl) return; - if (/404|not found/i.test(document.title)) { - sent404ForUrl = currentUrl; - trackCustomEvent('404'); - } - } - - // * Check on initial load (deferred so SPAs can set title) - setTimeout(check404, 0); - // * Auto-track scroll depth at 25%, 50%, 75%, and 100% (on by default) // * Each threshold fires once per pageview; resets on SPA navigation // * Opt-out: add data-no-scroll to the script tag -- 2.49.1 From dedb55b113c6da14ffcdbe4e49b8bf232422cc4f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 12:32:53 +0100 Subject: [PATCH 033/428] docs: add thin client changes to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba9082..9cd8f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Improved +- **Smaller, faster tracking script.** The tracking script is now about 20% smaller. Logic like page path cleaning, referrer filtering, error page detection, and input validation has been moved from your browser to the Pulse server. This means the script loads faster on every page, and Pulse can improve these features without needing you to update anything. +- **Automatic 404 page detection.** Pulse now detects error pages (404 / "Page Not Found") automatically on the server by reading your page title — no extra setup needed. Previously this ran in the browser and couldn't be improved without updating the script. Now Pulse can recognize more error page patterns over time, including pages in other languages, without any changes on your end. - **Smarter bot filtering.** Pulse now catches more types of automated traffic that were slipping through — like headless browsers with default screen sizes, bot farms that rotate through different locations, and bots that fire duplicate events within milliseconds. Bot detection checks have also been moved from the tracking script to the server, making the script smaller and faster for real visitors. - **Actionable empty states.** When a dashboard section has no data yet, you now get a direct action — like "Install tracking script" or "Build a UTM URL" — instead of just passive text. Gets you set up faster. - **Animated numbers across the dashboard.** Stats like visitors, pageviews, bounce rate, and visit duration now smoothly count up or down when you switch date ranges, apply filters, or when real-time visitor counts change — instead of just jumping to the new value. -- 2.49.1 From 3aaf199a19947a2984000723be47e174d98e4abf Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 19:47:54 +0100 Subject: [PATCH 034/428] chore: bump @ciphera-net/ui to ^0.2.8 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index df9c23b..c4243a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.15.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.7", + "@ciphera-net/ui": "^0.2.8", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1668,9 +1668,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.7", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.7/f5f170676cdd1bf53c091a0baa98c2d55a7c999f", - "integrity": "sha512-yvag9cYfX6c8aZ3bKI+i3l9ALJBXg7XL6soIjd65F7NyZN+1mEo1Fb+ARfWgjdNa5HjfexAnEOOVpjwMNPFCfg==", + "version": "0.2.8", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.8/3a78342207ee2351625b9469ec6030033df183cc", + "integrity": "sha512-I6B7fE2YXjJaipmcVS60q2pzhsy/NKM4sfvHIv4awi6mcrXjGag8FznW0sI1SbsplFWpoT5iSMtWIi/lZdFhbA==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index 0c5d64a..bf3e35d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.7", + "@ciphera-net/ui": "^0.2.8", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", -- 2.49.1 From d97818dfd7377bab6d8de120f9349aa802b97923 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 22:32:45 +0100 Subject: [PATCH 035/428] fix: use screen.width fallback in trackCustomEvent to prevent bot filter false positives window.innerWidth is 0 in hidden/minimized tabs, causing the heuristic bot scorer (added in #40) to drop legitimate custom events with a score of 5. Use window.screen.width as fallback, matching the existing trackPageview logic. --- public/script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/script.js b/public/script.js index 21632f6..34f078a 100644 --- a/public/script.js +++ b/public/script.js @@ -320,7 +320,7 @@ url: location.href, title: document.title, referrer: document.referrer || '', - screen: { width: window.innerWidth || 0, height: window.innerHeight || 0 }, + screen: { width: window.innerWidth || window.screen.width, height: window.innerHeight || window.screen.height }, session_id: getSessionId(), name: eventName.trim().toLowerCase(), }; -- 2.49.1 From 90944ce6bd0c4fe33fa7b251683c63ce84b564d9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 22:32:45 +0100 Subject: [PATCH 036/428] fix: use screen.width fallback in trackCustomEvent to prevent bot filter false positives window.innerWidth is 0 in hidden/minimized tabs, causing the heuristic bot scorer (added in #40) to drop legitimate custom events with a score of 5. Use window.screen.width as fallback, matching the existing trackPageview logic. --- public/script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/script.js b/public/script.js index 21632f6..34f078a 100644 --- a/public/script.js +++ b/public/script.js @@ -320,7 +320,7 @@ url: location.href, title: document.title, referrer: document.referrer || '', - screen: { width: window.innerWidth || 0, height: window.innerHeight || 0 }, + screen: { width: window.innerWidth || window.screen.width, height: window.innerHeight || window.screen.height }, session_id: getSessionId(), name: eventName.trim().toLowerCase(), }; -- 2.49.1 From 78fed269db45a52f9819defcb06314e651fa6380 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Tue, 17 Mar 2026 23:07:11 +0100 Subject: [PATCH 037/428] fix: replace developer jargon with user-friendly labels in visitor identity settings Storage/TTL labels used implementation terms (localStorage, sessionStorage, TTL) that only make sense to developers. Replaced with plain language and added a description explaining the privacy trade-off. --- components/sites/ScriptSetupBlock.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/components/sites/ScriptSetupBlock.tsx b/components/sites/ScriptSetupBlock.tsx index ac988a8..339fbfb 100644 --- a/components/sites/ScriptSetupBlock.tsx +++ b/components/sites/ScriptSetupBlock.tsx @@ -14,15 +14,15 @@ import { toast, Toggle, Select, CheckIcon } from '@ciphera-net/ui' const FRAMEWORKS = integrations.filter((i) => i.category === 'framework').slice(0, 10) const STORAGE_OPTIONS = [ - { value: 'local', label: 'Cross-tab (localStorage)' }, - { value: 'session', label: 'Per-tab (sessionStorage)' }, + { value: 'local', label: 'Across all tabs' }, + { value: 'session', label: 'Single tab only' }, ] const TTL_OPTIONS = [ - { value: '24', label: '24h' }, - { value: '48', label: '48h' }, - { value: '168', label: '7d' }, - { value: '720', label: '30d' }, + { value: '24', label: '24 hours' }, + { value: '48', label: '2 days' }, + { value: '168', label: '7 days' }, + { value: '720', label: '30 days' }, ] const FEATURES = [ @@ -187,13 +187,16 @@ export default function ScriptSetupBlock({ {/* ── Storage + TTL ───────────────────────────────────────────────── */}
-

+

Visitor identity

+

+ How returning visitors are recognized. Stricter settings increase privacy but may raise unique visitor counts. +

Date: Wed, 18 Mar 2026 10:48:22 +0100 Subject: [PATCH 038/428] feat: add soft-delete API functions and deleted_at to Site type --- lib/api/sites.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/api/sites.ts b/lib/api/sites.ts index 60d2f4b..7093b57 100644 --- a/lib/api/sites.ts +++ b/lib/api/sites.ts @@ -26,6 +26,7 @@ export interface Site { is_verified?: boolean created_at: string updated_at: string + deleted_at?: string | null } export interface CreateSiteRequest { @@ -77,8 +78,8 @@ export async function updateSite(id: string, data: UpdateSiteRequest): Promise { - await apiRequest(`/sites/${id}`, { +export async function deleteSite(id: string): Promise<{ message: string; purge_at: string }> { + return apiRequest<{ message: string; purge_at: string }>(`/sites/${id}`, { method: 'DELETE', }) } @@ -94,3 +95,20 @@ export async function verifySite(id: string): Promise { method: 'POST', }) } + +export async function restoreSite(id: string): Promise { + await apiRequest(`/sites/${id}/restore`, { + method: 'POST', + }) +} + +export async function permanentDeleteSite(id: string): Promise { + await apiRequest(`/sites/${id}/permanent`, { + method: 'DELETE', + }) +} + +export async function listDeletedSites(): Promise { + const response = await apiRequest<{ sites: Site[] }>('/sites/deleted') + return response?.sites || [] +} -- 2.49.1 From 7a0f106bc3b74615b0c81e5fb9123a9ee0565f40 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 10:49:06 +0100 Subject: [PATCH 039/428] feat: add DeleteSiteModal with soft-delete and permanent-delete options --- components/sites/DeleteSiteModal.tsx | 221 +++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 components/sites/DeleteSiteModal.tsx diff --git a/components/sites/DeleteSiteModal.tsx b/components/sites/DeleteSiteModal.tsx new file mode 100644 index 0000000..33c6c4e --- /dev/null +++ b/components/sites/DeleteSiteModal.tsx @@ -0,0 +1,221 @@ +'use client' + +import { useState } from 'react' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { toast } from '@ciphera-net/ui' +import { getAuthErrorMessage } from '@ciphera-net/ui' +import { AlertTriangleIcon, XIcon } from '@ciphera-net/ui' +import { deleteSite, permanentDeleteSite } from '@/lib/api/sites' + +interface DeleteSiteModalProps { + open: boolean + onClose: () => void + onDeleted: () => void + siteName: string + siteDomain: string + siteId: string +} + +export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, siteDomain, siteId }: DeleteSiteModalProps) { + const [deleteConfirm, setDeleteConfirm] = useState('') + const [isDeleting, setIsDeleting] = useState(false) + const [showPermanent, setShowPermanent] = useState(false) + const [permanentConfirm, setPermanentConfirm] = useState('') + const [isPermanentDeleting, setIsPermanentDeleting] = useState(false) + + const handleClose = () => { + setDeleteConfirm('') + setShowPermanent(false) + setPermanentConfirm('') + setIsDeleting(false) + setIsPermanentDeleting(false) + onClose() + } + + const handleSoftDelete = async () => { + if (deleteConfirm !== 'DELETE') return + setIsDeleting(true) + try { + await deleteSite(siteId) + toast.success('Site scheduled for deletion. You have 7 days to restore it.') + handleClose() + onDeleted() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to delete site') + } finally { + setIsDeleting(false) + } + } + + const handlePermanentDelete = async () => { + if (permanentConfirm !== siteDomain) return + setIsPermanentDeleting(true) + try { + await permanentDeleteSite(siteId) + toast.success('Site permanently deleted') + handleClose() + onDeleted() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to permanently delete site') + } finally { + setIsPermanentDeleting(false) + } + } + + if (typeof document === 'undefined') return null + + return createPortal( + + {open && ( + + +
+

Delete {siteName || 'Site'}?

+ +
+ + {!showPermanent ? ( + <> +

+ This site will be scheduled for deletion with a 7-day grace period. You can restore it at any time during this period. +

+ +
+
+ + + Site will stop collecting data immediately + +
+
+ + + All data will be purged after 7 days + +
+
+ +
+
+ + setDeleteConfirm(e.target.value)} + autoComplete="off" + className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" + placeholder="DELETE" + /> +
+ +
+ + +
+ + +
+ + ) : ( + <> +

+ This action is irreversible. The site and all its data will be permanently deleted immediately. +

+ +
+
+ + + All analytics data will be permanently lost + +
+
+ + + This cannot be undone + +
+
+ +
+
+ + setPermanentConfirm(e.target.value)} + autoComplete="off" + className="w-full px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-red-500 dark:focus:ring-red-400" + placeholder={siteDomain} + /> +
+ +
+ + +
+
+ + )} +
+
+ )} +
, + document.body + ) +} -- 2.49.1 From d7f374472afe0a8880a4ef39abf2089520c7c3b9 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 10:50:19 +0100 Subject: [PATCH 040/428] feat: integrate delete modal and soft-deleted sites list on dashboard --- app/page.tsx | 80 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 4501e98..caa5ee6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,12 +5,13 @@ import Link from 'next/link' import { motion } from 'framer-motion' import { useAuth } from '@/lib/auth/context' import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth' -import { listSites, deleteSite, type Site } from '@/lib/api/sites' +import { listSites, listDeletedSites, restoreSite, permanentDeleteSite, type Site } from '@/lib/api/sites' import { getStats } from '@/lib/api/stats' import type { Stats } from '@/lib/api/stats' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' import { LoadingOverlay } from '@ciphera-net/ui' import SiteList from '@/components/sites/SiteList' +import DeleteSiteModal from '@/components/sites/DeleteSiteModal' import { Button } from '@ciphera-net/ui' import Image from 'next/image' import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } from '@ciphera-net/ui' @@ -118,6 +119,8 @@ export default function HomePage() { const [subscription, setSubscription] = useState(null) const [subscriptionLoading, setSubscriptionLoading] = useState(false) const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true) + const [deleteModalSite, setDeleteModalSite] = useState(null) + const [deletedSites, setDeletedSites] = useState([]) useEffect(() => { if (user?.org_id) { @@ -178,6 +181,12 @@ export default function HomePage() { setSitesLoading(true) const data = await listSites() setSites(Array.isArray(data) ? data : []) + try { + const deleted = await listDeletedSites() + setDeletedSites(deleted) + } catch { + setDeletedSites([]) + } } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to load your sites') setSites([]) @@ -198,14 +207,26 @@ export default function HomePage() { } } - const handleDelete = async (id: string) => { - if (!confirm('Are you sure you want to delete this site? This action cannot be undone.')) { - return - } + const handleDelete = (id: string) => { + const site = sites.find((s) => s.id === id) + if (site) setDeleteModalSite(site) + } + const handleRestore = async (id: string) => { try { - await deleteSite(id) - toast.success('Site deleted successfully') + await restoreSite(id) + toast.success('Site restored successfully') + loadSites() + } catch (error: unknown) { + toast.error(getAuthErrorMessage(error) || 'Failed to restore site') + } + } + + const handlePermanentDelete = async (id: string) => { + if (!confirm('Permanently delete this site and all data? This cannot be undone.')) return + try { + await permanentDeleteSite(id) + toast.success('Site permanently deleted') loadSites() } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to delete site') @@ -512,6 +533,51 @@ export default function HomePage() { {(sitesLoading || sites.length > 0) && ( )} + + setDeleteModalSite(null)} + onDeleted={loadSites} + siteName={deleteModalSite?.name || ''} + siteDomain={deleteModalSite?.domain || ''} + siteId={deleteModalSite?.id || ''} + /> + + {deletedSites.length > 0 && ( +
+

Scheduled for Deletion

+
+ {deletedSites.map((site) => { + const purgeAt = site.deleted_at ? new Date(new Date(site.deleted_at).getTime() + 7 * 24 * 60 * 60 * 1000) : null + const daysLeft = purgeAt ? Math.max(0, Math.ceil((purgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : 0 + + return ( +
+
+ {site.name} + {site.domain} + Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''} +
+
+ + +
+
+ ) + })} +
+
+ )}
) } -- 2.49.1 From 51723bea5db5d561a0fce26d7a7ebd89367148bb Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 10:51:14 +0100 Subject: [PATCH 041/428] feat: replace prompt() delete with DeleteSiteModal on settings page --- app/sites/[id]/settings/page.tsx | 33 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx index b5dc03c..f3d5f85 100644 --- a/app/sites/[id]/settings/page.tsx +++ b/app/sites/[id]/settings/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites' +import { updateSite, resetSiteData, type Site, type GeoDataLevel } from '@/lib/api/sites' import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals' import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules' import { getGSCAuthURL, disconnectGSC } from '@/lib/api/gsc' @@ -13,6 +13,7 @@ import { getAuthErrorMessage } from '@ciphera-net/ui' import { formatDateTime } from '@/lib/utils/formatDate' import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import VerificationModal from '@/components/sites/VerificationModal' +import DeleteSiteModal from '@/components/sites/DeleteSiteModal' import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock' import { PasswordInput } from '@ciphera-net/ui' import { Select, Modal, Button } from '@ciphera-net/ui' @@ -59,6 +60,7 @@ export default function SiteSettingsPage() { const { data: site, isLoading: siteLoading, mutate: mutateSite } = useSite(siteId) const [saving, setSaving] = useState(false) + const [showDeleteModal, setShowDeleteModal] = useState(false) const [activeTab, setActiveTab] = useState<'general' | 'visibility' | 'data' | 'goals' | 'reports' | 'integrations'>('general') const searchParams = useSearchParams() @@ -462,20 +464,8 @@ export default function SiteSettingsPage() { } } - const handleDeleteSite = async () => { - const confirmation = prompt('To confirm deletion, please type the site domain:') - if (confirmation !== site?.domain) { - if (confirmation) toast.error('Domain does not match') - return - } - - try { - await deleteSite(siteId) - toast.success('Site deleted successfully') - router.push('/') - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to delete site') - } + const handleDeleteSite = () => { + setShowDeleteModal(true) } const copyLink = () => { @@ -804,13 +794,13 @@ export default function SiteSettingsPage() {

Delete Site

-

Permanently delete this site and all data.

+

Schedule this site for deletion with a 7-day grace period.

@@ -2038,6 +2028,15 @@ export default function SiteSettingsPage() { site={site} onVerified={() => mutateSite()} /> + + setShowDeleteModal(false)} + onDeleted={() => router.push('/')} + siteName={site?.name || ''} + siteDomain={site?.domain || ''} + siteId={siteId} + />
) } -- 2.49.1 From ad1c8c542072517d9c91eda2c86f9f31b2d6d260 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 10:58:47 +0100 Subject: [PATCH 042/428] fix: address spec compliance gaps in soft-delete frontend --- app/page.tsx | 29 ++++++++++++++++++---------- components/sites/DeleteSiteModal.tsx | 6 +++--- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index caa5ee6..e577d7f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -408,15 +408,22 @@ export default function HomePage() { const siteLimit = getSitesLimitForPlan(subscription?.plan_id) const atLimit = siteLimit != null && sites.length >= siteLimit return atLimit ? ( -
- - Limit reached ({sites.length}/{siteLimit}) - - - - +
+
+ + Limit reached ({sites.length}/{siteLimit}) + + + + +
+ {deletedSites.length > 0 && ( +

+ You have a site pending deletion. Restore it or permanently delete it to free the slot. +

+ )}
) : null })() ?? ( @@ -556,7 +563,9 @@ export default function HomePage() {
{site.name} {site.domain} - Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''} + + Deleting in {daysLeft} day{daysLeft !== 1 ? 's' : ''} +
- All data will be purged after 7 days + Report schedules and goals
@@ -149,7 +149,7 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si onClick={() => setShowPermanent(true)} className="w-full text-center text-xs text-neutral-400 hover:text-red-500 dark:hover:text-red-400 transition-colors" > - Permanently delete now + Permanently delete now (cannot be undone)
-- 2.49.1 From 311f54626136acc3bf611fd576d835e2e8a6f2e4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 11:03:44 +0100 Subject: [PATCH 043/428] fix: improve code quality in soft-delete frontend (loading state, imports, confirm dialog) --- app/page.tsx | 25 ++++++++++++++---------- components/sites/DeleteSiteModal.tsx | 29 +++++++++++++++++----------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index e577d7f..b4ac71d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { motion } from 'framer-motion' import { useAuth } from '@/lib/auth/context' import { initiateOAuthFlow, initiateSignupFlow } from '@/lib/api/oauth' -import { listSites, listDeletedSites, restoreSite, permanentDeleteSite, type Site } from '@/lib/api/sites' +import { listSites, listDeletedSites, restoreSite, type Site } from '@/lib/api/sites' import { getStats } from '@/lib/api/stats' import type { Stats } from '@/lib/api/stats' import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing' @@ -121,6 +121,7 @@ export default function HomePage() { const [showFinishSetupBanner, setShowFinishSetupBanner] = useState(true) const [deleteModalSite, setDeleteModalSite] = useState(null) const [deletedSites, setDeletedSites] = useState([]) + const [permanentDeleteSiteModal, setPermanentDeleteSiteModal] = useState(null) useEffect(() => { if (user?.org_id) { @@ -222,15 +223,9 @@ export default function HomePage() { } } - const handlePermanentDelete = async (id: string) => { - if (!confirm('Permanently delete this site and all data? This cannot be undone.')) return - try { - await permanentDeleteSite(id) - toast.success('Site permanently deleted') - loadSites() - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to delete site') - } + const handlePermanentDelete = (id: string) => { + const site = deletedSites.find((s) => s.id === id) + if (site) setPermanentDeleteSiteModal(site) } if (authLoading) { @@ -550,6 +545,16 @@ export default function HomePage() { siteId={deleteModalSite?.id || ''} /> + setPermanentDeleteSiteModal(null)} + onDeleted={loadSites} + siteName={permanentDeleteSiteModal?.name || ''} + siteDomain={permanentDeleteSiteModal?.domain || ''} + siteId={permanentDeleteSiteModal?.id || ''} + permanentOnly + /> + {deletedSites.length > 0 && (

Scheduled for Deletion

diff --git a/components/sites/DeleteSiteModal.tsx b/components/sites/DeleteSiteModal.tsx index 5f01d26..b8f7bb7 100644 --- a/components/sites/DeleteSiteModal.tsx +++ b/components/sites/DeleteSiteModal.tsx @@ -1,11 +1,9 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { createPortal } from 'react-dom' import { motion, AnimatePresence } from 'framer-motion' -import { toast } from '@ciphera-net/ui' -import { getAuthErrorMessage } from '@ciphera-net/ui' -import { AlertTriangleIcon, XIcon } from '@ciphera-net/ui' +import { toast, getAuthErrorMessage, AlertTriangleIcon, XIcon } from '@ciphera-net/ui' import { deleteSite, permanentDeleteSite } from '@/lib/api/sites' interface DeleteSiteModalProps { @@ -15,15 +13,22 @@ interface DeleteSiteModalProps { siteName: string siteDomain: string siteId: string + permanentOnly?: boolean } -export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, siteDomain, siteId }: DeleteSiteModalProps) { +export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, siteDomain, siteId, permanentOnly }: DeleteSiteModalProps) { const [deleteConfirm, setDeleteConfirm] = useState('') const [isDeleting, setIsDeleting] = useState(false) - const [showPermanent, setShowPermanent] = useState(false) + const [showPermanent, setShowPermanent] = useState(!!permanentOnly) const [permanentConfirm, setPermanentConfirm] = useState('') const [isPermanentDeleting, setIsPermanentDeleting] = useState(false) + useEffect(() => { + if (open && permanentOnly) { + setShowPermanent(true) + } + }, [open, permanentOnly]) + const handleClose = () => { setDeleteConfirm('') setShowPermanent(false) @@ -43,7 +48,6 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si onDeleted() } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to delete site') - } finally { setIsDeleting(false) } } @@ -58,7 +62,6 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si onDeleted() } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to permanently delete site') - } finally { setIsPermanentDeleting(false) } } @@ -193,13 +196,17 @@ export default function DeleteSiteModal({ open, onClose, onDeleted, siteName, si -
-
- ))} - - -
- -
- - - - -
- -
+ ) } diff --git a/components/funnels/FunnelForm.tsx b/components/funnels/FunnelForm.tsx new file mode 100644 index 0000000..0c3b0a1 --- /dev/null +++ b/components/funnels/FunnelForm.tsx @@ -0,0 +1,519 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { Input, Button, ChevronLeftIcon, ChevronDownIcon, PlusIcon, TrashIcon } from '@ciphera-net/ui' +import { CaretUp } from '@phosphor-icons/react' +import type { FunnelStep, StepPropertyFilter, CreateFunnelRequest } from '@/lib/api/funnels' + +type StepWithoutOrder = Omit + +interface FunnelFormProps { + siteId: string + initialData?: { + name: string + description: string + steps: StepWithoutOrder[] + conversion_window_value: number + conversion_window_unit: 'hours' | 'days' + } + onSubmit: (data: CreateFunnelRequest) => Promise + submitLabel: string + cancelHref: string +} + +function isValidRegex(pattern: string): boolean { + try { + new RegExp(pattern) + return true + } catch { + return false + } +} + +const WINDOW_PRESETS = [ + { label: '1h', value: 1, unit: 'hours' as const }, + { label: '24h', value: 24, unit: 'hours' as const }, + { label: '7d', value: 7, unit: 'days' as const }, + { label: '14d', value: 14, unit: 'days' as const }, + { label: '30d', value: 30, unit: 'days' as const }, +] + +const OPERATOR_OPTIONS: { value: StepPropertyFilter['operator']; label: string }[] = [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + { value: 'contains', label: 'contains' }, + { value: 'not_contains', label: 'does not contain' }, +] + +const MAX_STEPS = 8 +const MAX_FILTERS = 10 + +export default function FunnelForm({ siteId, initialData, onSubmit, submitLabel, cancelHref }: FunnelFormProps) { + const [name, setName] = useState(initialData?.name ?? '') + const [description, setDescription] = useState(initialData?.description ?? '') + const [steps, setSteps] = useState( + initialData?.steps ?? [ + { name: 'Step 1', value: '/', type: 'exact' }, + { name: 'Step 2', value: '', type: 'exact' }, + ] + ) + const [windowValue, setWindowValue] = useState(initialData?.conversion_window_value ?? 7) + const [windowUnit, setWindowUnit] = useState<'hours' | 'days'>(initialData?.conversion_window_unit ?? 'days') + + const handleAddStep = () => { + if (steps.length >= MAX_STEPS) return + setSteps([...steps, { name: `Step ${steps.length + 1}`, value: '', type: 'exact' }]) + } + + const handleRemoveStep = (index: number) => { + if (steps.length <= 1) return + setSteps(steps.filter((_, i) => i !== index)) + } + + const handleUpdateStep = (index: number, field: string, value: string) => { + const newSteps = [...steps] + const step = { ...newSteps[index] } + + if (field === 'category') { + step.category = value as 'page' | 'event' + // Reset fields when switching category + if (value === 'event') { + step.type = 'exact' + step.value = '' + } else { + step.value = '' + step.property_filters = undefined + } + } else { + ;(step as Record)[field] = value + } + + newSteps[index] = step + setSteps(newSteps) + } + + const moveStep = (index: number, direction: -1 | 1) => { + const targetIndex = index + direction + if (targetIndex < 0 || targetIndex >= steps.length) return + const newSteps = [...steps] + const temp = newSteps[index] + newSteps[index] = newSteps[targetIndex] + newSteps[targetIndex] = temp + setSteps(newSteps) + } + + // Property filter handlers + const addPropertyFilter = (stepIndex: number) => { + const newSteps = [...steps] + const step = { ...newSteps[stepIndex] } + const filters = [...(step.property_filters || [])] + if (filters.length >= MAX_FILTERS) return + filters.push({ key: '', operator: 'is', value: '' }) + step.property_filters = filters + newSteps[stepIndex] = step + setSteps(newSteps) + } + + const updatePropertyFilter = (stepIndex: number, filterIndex: number, field: keyof StepPropertyFilter, value: string) => { + const newSteps = [...steps] + const step = { ...newSteps[stepIndex] } + const filters = [...(step.property_filters || [])] + filters[filterIndex] = { ...filters[filterIndex], [field]: value } + step.property_filters = filters + newSteps[stepIndex] = step + setSteps(newSteps) + } + + const removePropertyFilter = (stepIndex: number, filterIndex: number) => { + const newSteps = [...steps] + const step = { ...newSteps[stepIndex] } + const filters = [...(step.property_filters || [])] + filters.splice(filterIndex, 1) + step.property_filters = filters.length > 0 ? filters : undefined + newSteps[stepIndex] = step + setSteps(newSteps) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!name.trim()) { + const { toast } = await import('@ciphera-net/ui') + toast.error('Please enter a funnel name') + return + } + + if (steps.some(s => !s.name.trim())) { + const { toast } = await import('@ciphera-net/ui') + toast.error('Please enter a name for all steps') + return + } + + // Validate based on category + for (const step of steps) { + const category = step.category || 'page' + + if (!step.value.trim()) { + const { toast } = await import('@ciphera-net/ui') + toast.error(category === 'event' + ? `Please enter an event name for step: ${step.name}` + : `Please enter a path for step: ${step.name}`) + return + } + + if (category === 'page' && step.type === 'regex' && !isValidRegex(step.value)) { + const { toast } = await import('@ciphera-net/ui') + toast.error(`Invalid regex pattern in step: ${step.name}`) + return + } + + if (category === 'event' && step.property_filters) { + for (const filter of step.property_filters) { + if (!filter.key.trim()) { + const { toast } = await import('@ciphera-net/ui') + toast.error(`Property filter key is required in step: ${step.name}`) + return + } + } + } + } + + const funnelSteps = steps.map((s, i) => ({ + ...s, + order: i, + })) + + await onSubmit({ + name, + description, + steps: funnelSteps, + conversion_window_value: windowValue, + conversion_window_unit: windowUnit, + }) + } + + const selectClass = 'px-2 py-2 bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg text-sm focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none' + + return ( +
+
+ + + Back to Funnels + + +

+ {initialData ? 'Edit Funnel' : 'Create New Funnel'} +

+

+ Define the steps users take to complete a goal. +

+
+ +
+ {/* Name & Description */} +
+
+
+ + setName(e.target.value)} + placeholder="e.g. Signup Flow" + autoFocus + required + maxLength={100} + /> + {name.length > 80 && ( + 90 ? 'text-amber-500' : 'text-neutral-400'}`}> + {name.length}/100 + + )} +
+
+ + setDescription(e.target.value)} + placeholder="Tracks users from landing page to signup" + /> +
+
+
+ + {/* Steps */} +
+
+

+ Funnel Steps +

+
+ + {steps.map((step, index) => { + const category = step.category || 'page' + + return ( +
+
+ {/* Step number + reorder */} +
+
+ {index + 1} +
+
+ + +
+
+ +
+ {/* Category toggle */} +
+ + +
+ +
+
+ + handleUpdateStep(index, 'name', e.target.value)} + placeholder="e.g. Landing Page" + /> +
+ + {category === 'page' ? ( +
+ +
+ + handleUpdateStep(index, 'value', e.target.value)} + placeholder={step.type === 'exact' ? '/pricing' : 'pricing'} + className="flex-1" + /> +
+
+ ) : ( +
+ + handleUpdateStep(index, 'value', e.target.value)} + placeholder="e.g. signup, purchase" + /> +
+ )} +
+ + {/* Property filters (event steps only) */} + {category === 'event' && ( +
+ {step.property_filters && step.property_filters.length > 0 && ( +
+ {step.property_filters.map((filter, filterIndex) => ( +
+ updatePropertyFilter(index, filterIndex, 'key', e.target.value)} + placeholder="key" + className="flex-1" + /> + + updatePropertyFilter(index, filterIndex, 'value', e.target.value)} + placeholder="value" + className="flex-1" + /> + +
+ ))} +
+ )} + {(!step.property_filters || step.property_filters.length < MAX_FILTERS) && ( + + )} +
+ )} +
+ + +
+
+ ) + })} + + {steps.length < MAX_STEPS ? ( + + ) : ( +

Maximum 8 steps

+ )} +
+ + {/* Conversion Window */} +
+

+ Conversion Window +

+

+ Visitors must complete all steps within this time to count as converted. +

+ + {/* Quick presets */} +
+ {WINDOW_PRESETS.map(preset => ( + + ))} +
+ + {/* Custom input */} +
+ setWindowValue(Math.max(1, parseInt(e.target.value) || 1))} + className="w-20" + /> + +
+
+ +
+ + + + +
+
+
+ ) +} -- 2.49.1 From 2811945d3e0fc996321ec42d8a6e86c0a2297b66 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:26:26 +0100 Subject: [PATCH 046/428] feat(funnels): add filter bar and exit path display to funnel detail --- app/sites/[id]/funnels/[funnelId]/page.tsx | 121 ++++++++++++++------- 1 file changed, 84 insertions(+), 37 deletions(-) diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 655abe3..4936335 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -1,9 +1,12 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import { ApiError } from '@/lib/api/client' import { getFunnel, getFunnelStats, deleteFunnel, type Funnel, type FunnelStats } from '@/lib/api/funnels' +import FilterBar from '@/components/dashboard/FilterBar' +import AddFilterDropdown from '@/components/dashboard/AddFilterDropdown' +import { type DimensionFilter, serializeFilters } from '@/lib/filters' import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui' import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import Link from 'next/link' @@ -23,6 +26,8 @@ export default function FunnelReportPage() { const [datePreset, setDatePreset] = useState<'7' | '30' | 'custom'>('30') const [isDatePickerOpen, setIsDatePickerOpen] = useState(false) const [loadError, setLoadError] = useState<'not_found' | 'forbidden' | 'error' | null>(null) + const [filters, setFilters] = useState([]) + const [expandedExitStep, setExpandedExitStep] = useState(null) const loadData = useCallback(async () => { setLoadError(null) @@ -30,7 +35,7 @@ export default function FunnelReportPage() { setLoading(true) const [funnelData, statsData] = await Promise.all([ getFunnel(siteId, funnelId), - getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end) + getFunnelStats(siteId, funnelId, dateRange.start, dateRange.end, serializeFilters(filters) || undefined) ]) setFunnel(funnelData) setStats(statsData) @@ -43,7 +48,7 @@ export default function FunnelReportPage() { } finally { setLoading(false) } - }, [siteId, funnelId, dateRange]) + }, [siteId, funnelId, dateRange, filters]) useEffect(() => { loadData() @@ -167,6 +172,18 @@ export default function FunnelReportPage() {
+ {/* Filters */} +
+ setFilters(prev => [...prev, f])} + /> + setFilters(prev => prev.filter((_, idx) => idx !== i))} + onClear={() => setFilters([])} + /> +
+ {/* Chart */}

@@ -195,42 +212,72 @@ export default function FunnelReportPage() { {stats.steps.map((step, i) => ( - - -
- - {i + 1} - -
-

{step.step.name}

-

{step.step.value}

+ + + +
+ + {i + 1} + +
+

{step.step.name}

+

{step.step.value}

+
-
- - - - {step.visitors.toLocaleString()} - - - - {i > 0 ? ( - 50 - ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' - : 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300' - }`}> - {Math.round(step.dropoff)}% + + + + {step.visitors.toLocaleString()} - ) : ( - - - )} - - - - {Math.round(step.conversion)}% - - - + + + {i > 0 ? ( + 50 + ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' + : 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-300' + }`}> + {Math.round(step.dropoff)}% + + ) : ( + - + )} + + + + {Math.round(step.conversion)}% + + + + {step.exit_pages && step.exit_pages.length > 0 && ( + + +
+

+ Where visitors went after dropping off: +

+
+ {(expandedExitStep === i ? step.exit_pages : step.exit_pages.slice(0, 3)).map(ep => ( + + {ep.path} + {ep.visitors} + + ))} +
+ {step.exit_pages.length > 3 && ( + + )} +
+ + + )} + ))} -- 2.49.1 From 585cb4fd8837d1e3fdc6418343ff5b984087e87e Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:27:45 +0100 Subject: [PATCH 047/428] feat(funnels): add edit funnel page with pre-populated form --- .../[id]/funnels/[funnelId]/edit/page.tsx | 58 +++++++++++++++++++ app/sites/[id]/funnels/[funnelId]/page.tsx | 8 +++ 2 files changed, 66 insertions(+) create mode 100644 app/sites/[id]/funnels/[funnelId]/edit/page.tsx diff --git a/app/sites/[id]/funnels/[funnelId]/edit/page.tsx b/app/sites/[id]/funnels/[funnelId]/edit/page.tsx new file mode 100644 index 0000000..cea7f0f --- /dev/null +++ b/app/sites/[id]/funnels/[funnelId]/edit/page.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useSWRConfig } from 'swr' +import { getFunnel, updateFunnel, type Funnel, type CreateFunnelRequest } from '@/lib/api/funnels' +import { toast } from '@ciphera-net/ui' +import FunnelForm from '@/components/funnels/FunnelForm' +import { FunnelDetailSkeleton } from '@/components/skeletons' + +export default function EditFunnelPage() { + const params = useParams() + const router = useRouter() + const { mutate } = useSWRConfig() + const siteId = params.id as string + const funnelId = params.funnelId as string + const [funnel, setFunnel] = useState(null) + const [saving, setSaving] = useState(false) + + useEffect(() => { + getFunnel(siteId, funnelId).then(setFunnel).catch(() => { + toast.error('Failed to load funnel') + router.push(`/sites/${siteId}/funnels`) + }) + }, [siteId, funnelId, router]) + + const handleSubmit = async (data: CreateFunnelRequest) => { + try { + setSaving(true) + await updateFunnel(siteId, funnelId, data) + await mutate(['funnels', siteId]) + toast.success('Funnel updated') + router.push(`/sites/${siteId}/funnels/${funnelId}`) + } catch { + toast.error('Failed to update funnel. Please try again.') + } finally { + setSaving(false) + } + } + + if (!funnel) return + + return ( + rest), + conversion_window_value: funnel.conversion_window_value, + conversion_window_unit: funnel.conversion_window_unit, + }} + onSubmit={handleSubmit} + submitLabel={saving ? 'Saving...' : 'Save Changes'} + cancelHref={`/sites/${siteId}/funnels/${funnelId}`} + /> + ) +} diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 4936335..5e688d9 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -8,6 +8,7 @@ import FilterBar from '@/components/dashboard/FilterBar' import AddFilterDropdown from '@/components/dashboard/AddFilterDropdown' import { type DimensionFilter, serializeFilters } from '@/lib/filters' import { toast, Select, DatePicker, ChevronLeftIcon, ArrowRightIcon, TrashIcon, Button } from '@ciphera-net/ui' +import { PencilSimple } from '@phosphor-icons/react' import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import Link from 'next/link' import { FunnelChart } from '@/components/ui/funnel-chart' @@ -162,6 +163,13 @@ export default function FunnelReportPage() { ]} /> + + + + ))} +
+

+ +
+ + + + + `${v}%`} + tick={{ fontSize: 12 }} + className="text-neutral-500" + /> + [`${value}%`]} + contentStyle={{ + backgroundColor: 'var(--color-neutral-900, #171717)', + border: '1px solid var(--color-neutral-700, #404040)', + borderRadius: '8px', + color: '#fff', + fontSize: '12px', + }} + /> + + {Array.from(visibleSteps).map((stepKey) => ( + + ))} + + +
+
+ )} + {/* Detailed Stats Table */}
-- 2.49.1 From 4c7ed858f71e17936c41449b7f4169d7e13780be Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:34:07 +0100 Subject: [PATCH 049/428] feat(funnels): add step-level breakdown drawer with dimension tabs --- app/sites/[id]/funnels/[funnelId]/page.tsx | 17 +++- components/funnels/BreakdownDrawer.tsx | 111 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 components/funnels/BreakdownDrawer.tsx diff --git a/app/sites/[id]/funnels/[funnelId]/page.tsx b/app/sites/[id]/funnels/[funnelId]/page.tsx index 7050a1d..9fd7a5c 100644 --- a/app/sites/[id]/funnels/[funnelId]/page.tsx +++ b/app/sites/[id]/funnels/[funnelId]/page.tsx @@ -13,6 +13,7 @@ import { FunnelDetailSkeleton, useMinimumLoading, useSkeletonFade } from '@/comp import Link from 'next/link' import { FunnelChart } from '@/components/ui/funnel-chart' import { getDateRange } from '@ciphera-net/ui' +import BreakdownDrawer from '@/components/funnels/BreakdownDrawer' import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts' export default function FunnelReportPage() { @@ -32,6 +33,7 @@ export default function FunnelReportPage() { const [expandedExitStep, setExpandedExitStep] = useState(null) const [trends, setTrends] = useState(null) const [visibleSteps, setVisibleSteps] = useState>(new Set()) + const [breakdownStep, setBreakdownStep] = useState(null) const loadData = useCallback(async () => { setLoadError(null) @@ -326,7 +328,7 @@ export default function FunnelReportPage() { {stats.steps.map((step, i) => ( - + setBreakdownStep(i)}>
@@ -398,6 +400,19 @@ export default function FunnelReportPage() {
+ {breakdownStep !== null && stats && ( + setBreakdownStep(null)} + /> + )} + setIsDatePickerOpen(false)} diff --git a/components/funnels/BreakdownDrawer.tsx b/components/funnels/BreakdownDrawer.tsx new file mode 100644 index 0000000..fc605ab --- /dev/null +++ b/components/funnels/BreakdownDrawer.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { getFunnelBreakdown, type FunnelBreakdown } from '@/lib/api/funnels' +import { DIMENSION_LABELS } from '@/lib/filters' + +const BREAKDOWN_DIMENSIONS = [ + 'device', 'country', 'browser', 'os', + 'utm_source', 'utm_medium', 'utm_campaign' +] + +interface BreakdownDrawerProps { + siteId: string + funnelId: string + stepIndex: number + stepName: string + startDate: string + endDate: string + filters?: string + onClose: () => void +} + +export default function BreakdownDrawer({ siteId, funnelId, stepIndex, stepName, startDate, endDate, filters, onClose }: BreakdownDrawerProps) { + const [activeDimension, setActiveDimension] = useState('device') + const [breakdown, setBreakdown] = useState(null) + const [loading, setLoading] = useState(true) + + const loadBreakdown = useCallback(async () => { + setLoading(true) + try { + const data = await getFunnelBreakdown(siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters) + setBreakdown(data) + } catch { + setBreakdown(null) + } finally { + setLoading(false) + } + }, [siteId, funnelId, stepIndex, activeDimension, startDate, endDate, filters]) + + useEffect(() => { + loadBreakdown() + }, [loadBreakdown]) + + return ( + <> + {/* Backdrop */} +
+ +
+ {/* Header */} +
+
+

Step Breakdown

+

{stepName}

+
+ +
+ + {/* Dimension tabs */} +
+ {BREAKDOWN_DIMENSIONS.map(dim => ( + + ))} +
+ + {/* Content */} +
+ {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : !breakdown || breakdown.entries.length === 0 ? ( +

No data for this dimension

+ ) : ( +
+ {breakdown.entries.map(entry => ( +
+ + {entry.value || '(unknown)'} + +
+ {entry.visitors} + + {Math.round(entry.conversion)}% + +
+
+ ))} +
+ )} +
+
+ + ) +} -- 2.49.1 From 94112161f07a5adbb580650445f898285821c95b Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 14:38:01 +0100 Subject: [PATCH 050/428] docs: update changelog for funnels v2 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd8f94..1a68ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- **Funnels now track actions, not just pages.** When creating or editing a funnel, you can now choose between "Page Visit" and "Custom Event" for each step. Page Visit steps work as before — matching URLs. Custom Event steps let you track specific actions like signups, purchases, or button clicks. You can also add property filters to event steps (e.g., "purchase where plan is pro") to get even more specific about what you're measuring. +- **Edit your funnels.** You can now edit existing funnels — change the name, description, steps, or conversion window without having to delete and recreate them. Click the pencil icon on any funnel's detail page. +- **Conversion window.** Funnels now have a configurable time limit. Visitors must complete all steps within your chosen window (e.g., 7 days, 24 hours) to count as converted. Set it when creating or editing a funnel — quick presets for common windows, or type your own. Default is 7 days. +- **Filter your funnels.** Apply the same filters you use on the dashboard — by device, country, browser, UTM source, and more — directly on your funnel stats. See how your funnel performs for mobile visitors vs desktop, or for traffic from a specific campaign. +- **See where visitors go after dropping off.** Each funnel step now shows the top pages visitors navigated to after leaving the funnel. A quick preview appears inline, and you can expand to see the full list. Helps you understand why visitors aren't converting. +- **Conversion trends over time.** A new chart below your funnel shows how conversion rates change day by day. See at a glance whether your funnel is improving or degrading. Toggle individual steps on or off to pinpoint which step is changing. +- **Step-level breakdowns.** Click any step in your funnel stats to open a breakdown panel showing who converts at that step — split by device, country, browser, or traffic source. Useful for spotting segments that convert better or worse than average. +- **Up to 8 steps per funnel.** The step limit has been increased from 5 to 8, so you can track longer user journeys like multi-page onboarding flows or detailed checkout processes. - **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. When connecting, Pulse automatically filters your pull zones to only show ones matching your site's domain. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time. - **Google Search Console integration.** Connect your Google Search Console account in Settings > Integrations to see which search queries bring visitors to your site. A new "Search" tab on your dashboard shows total clicks, impressions, average CTR, and average ranking position — with percentage changes compared to the previous period. Browse your top search queries and top pages in sortable, paginated tables. Click any query to see which pages rank for it, or click any page to see which queries drive traffic to it. A trend chart shows how clicks and impressions change over time, and a green badge highlights new queries that appeared this period. Pulse only requests read-only access to your Search Console data, encrypts your Google credentials, and lets you disconnect and fully remove all search data at any time. - **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free. -- 2.49.1 From 80ae8311dc7b60af1dc79799eb7163ba5f7546ef Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 15:30:17 +0100 Subject: [PATCH 051/428] feat: static header + collapsible sidebar navigation Replace floating pill header with static variant for authenticated views. Add collapsible sidebar with site picker, grouped navigation (Analytics/Infrastructure), and mobile overlay drawer. Remove horizontal SiteNav tab bar. --- app/layout-content.tsx | 68 +++-- app/sites/[id]/SiteLayoutShell.tsx | 9 +- components/dashboard/DashboardShell.tsx | 23 ++ components/dashboard/Sidebar.tsx | 364 ++++++++++++++++++++++++ lib/sidebar-context.tsx | 31 ++ package-lock.json | 8 +- package.json | 2 +- 7 files changed, 474 insertions(+), 31 deletions(-) create mode 100644 components/dashboard/DashboardShell.tsx create mode 100644 components/dashboard/Sidebar.tsx create mode 100644 lib/sidebar-context.tsx diff --git a/app/layout-content.tsx b/app/layout-content.tsx index e5f2caa..ccf5075 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -2,7 +2,7 @@ import { OfflineBanner } from '@/components/OfflineBanner' import { Footer } from '@/components/Footer' -import { Header, type CipheraApp } from '@ciphera-net/ui' +import { Header, type CipheraApp, MenuIcon } from '@ciphera-net/ui' import NotificationCenter from '@/components/notifications/NotificationCenter' import { useAuth } from '@/lib/auth/context' import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' @@ -15,6 +15,7 @@ import { LoadingOverlay } from '@ciphera-net/ui' import { useRouter } from 'next/navigation' import { SettingsModalProvider, useSettingsModal } from '@/lib/settings-modal-context' import SettingsModalWrapper from '@/components/settings/SettingsModalWrapper' +import { SidebarProvider, useSidebar } from '@/lib/sidebar-context' const ORG_SWITCH_KEY = 'pulse_switching_org' @@ -46,6 +47,19 @@ const CIPHERA_APPS: CipheraApp[] = [ }, ] +function MobileSidebarToggle() { + const { openMobile } = useSidebar() + return ( + + ) +} + function LayoutInner({ children }: { children: React.ReactNode }) { const auth = useAuth() const router = useRouter() @@ -91,23 +105,22 @@ function LayoutInner({ children }: { children: React.ReactNode }) { router.push('/onboarding') } - const showOfflineBar = Boolean(auth.user && !isOnline); - const barHeightRem = 2.5; - const headerHeightRem = 6; - const mainTopPaddingRem = barHeightRem + headerHeightRem; + const isAuthenticated = !!auth.user + const showOfflineBar = Boolean(auth.user && !isOnline) if (isSwitchingOrg) { return } return ( - <> +
{auth.user && }
: null} apps={CIPHERA_APPS} currentAppId="pulse" onOpenSettings={openSettings} + leftActions={isAuthenticated ? : undefined} customNavItems={ <> {!auth.user && ( @@ -134,26 +148,40 @@ function LayoutInner({ children }: { children: React.ReactNode }) { } /> -
- {children} -
-
+ {isAuthenticated ? ( + // Authenticated: sidebar layout — children include DashboardShell + <>{children} + ) : ( + // Public: standard content with footer + <> +
+ {children} +
+
+ + )} + {isAuthenticated && ( +
+ )} - +
) } export default function LayoutContent({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ) } diff --git a/app/sites/[id]/SiteLayoutShell.tsx b/app/sites/[id]/SiteLayoutShell.tsx index 1bc2a07..8879945 100644 --- a/app/sites/[id]/SiteLayoutShell.tsx +++ b/app/sites/[id]/SiteLayoutShell.tsx @@ -1,6 +1,6 @@ 'use client' -import SiteNav from '@/components/dashboard/SiteNav' +import DashboardShell from '@/components/dashboard/DashboardShell' export default function SiteLayoutShell({ siteId, @@ -10,11 +10,8 @@ export default function SiteLayoutShell({ children: React.ReactNode }) { return ( - <> -
- -
+ {children} - + ) } diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx new file mode 100644 index 0000000..0488ba5 --- /dev/null +++ b/components/dashboard/DashboardShell.tsx @@ -0,0 +1,23 @@ +'use client' + +import Sidebar from './Sidebar' +import { useSidebar } from '@/lib/sidebar-context' + +export default function DashboardShell({ + siteId, + children, +}: { + siteId: string + children: React.ReactNode +}) { + const { mobileOpen, closeMobile } = useSidebar() + + return ( +
+ +
+ {children} +
+
+ ) +} diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx new file mode 100644 index 0000000..34e9f9c --- /dev/null +++ b/components/dashboard/Sidebar.tsx @@ -0,0 +1,364 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import Link from 'next/link' +import { usePathname, useRouter } from 'next/navigation' +import { listSites, type Site } from '@/lib/api/sites' +import { useAuth } from '@/lib/auth/context' +import { + LayoutDashboardIcon, + PathIcon, + FunnelIcon, + CursorClickIcon, + SearchIcon, + CloudUploadIcon, + HeartbeatIcon, + SettingsIcon, + CollapseLeftIcon, + CollapseRightIcon, + ChevronUpDownIcon, + PlusIcon, + XIcon, + MenuIcon, +} from '@ciphera-net/ui' + +const SIDEBAR_COLLAPSED_KEY = 'pulse_sidebar_collapsed' + +interface NavItem { + label: string + href: (siteId: string) => string + icon: React.ComponentType<{ className?: string; weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' }> + matchPrefix?: boolean +} + +interface NavGroup { + label: string + items: NavItem[] +} + +const NAV_GROUPS: NavGroup[] = [ + { + label: 'Analytics', + items: [ + { label: 'Dashboard', href: (id) => `/sites/${id}`, icon: LayoutDashboardIcon }, + { label: 'Journeys', href: (id) => `/sites/${id}/journeys`, icon: PathIcon, matchPrefix: true }, + { label: 'Funnels', href: (id) => `/sites/${id}/funnels`, icon: FunnelIcon, matchPrefix: true }, + { label: 'Behavior', href: (id) => `/sites/${id}/behavior`, icon: CursorClickIcon, matchPrefix: true }, + { label: 'Search', href: (id) => `/sites/${id}/search`, icon: SearchIcon, matchPrefix: true }, + ], + }, + { + label: 'Infrastructure', + items: [ + { label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true }, + { label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true }, + ], + }, +] + +const SETTINGS_ITEM: NavItem = { + label: 'Settings', + href: (id) => `/sites/${id}/settings`, + icon: SettingsIcon, + matchPrefix: true, +} + +function SitePicker({ + sites, + currentSiteId, + collapsed, +}: { + sites: Site[] + currentSiteId: string + collapsed: boolean +}) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const ref = useRef(null) + const pathname = usePathname() + const router = useRouter() + + const currentSite = sites.find((s) => s.id === currentSiteId) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false) + setSearch('') + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const filtered = sites.filter( + (s) => + s.name.toLowerCase().includes(search.toLowerCase()) || + s.domain.toLowerCase().includes(search.toLowerCase()) + ) + + const switchSite = (siteId: string) => { + // Preserve current page type + const currentPageType = pathname.replace(/^\/sites\/[^/]+/, '') + router.push(`/sites/${siteId}${currentPageType}`) + setOpen(false) + setSearch('') + } + + const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?' + + return ( +
+ + + {open && ( +
+
+ setSearch(e.target.value)} + className="w-full px-3 py-1.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-neutral-900 dark:text-white placeholder:text-neutral-400" + autoFocus + /> +
+
+ {filtered.map((site) => ( + + ))} + {filtered.length === 0 && ( +

No sites found

+ )} +
+
+ setOpen(false)} + className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg transition-colors" + > + + Add new site + +
+
+ )} +
+ ) +} + +function NavItemLink({ + item, + siteId, + collapsed, + onClick, +}: { + item: NavItem + siteId: string + collapsed: boolean + onClick?: () => void +}) { + const pathname = usePathname() + const href = item.href(siteId) + const isActive = item.matchPrefix ? pathname.startsWith(href) : pathname === href + + return ( + + + {!collapsed && {item.label}} + + ) +} + +export default function Sidebar({ + siteId, + mobileOpen, + onMobileClose, +}: { + siteId: string + mobileOpen: boolean + onMobileClose: () => void +}) { + const { user } = useAuth() + const canEdit = user?.role === 'owner' || user?.role === 'admin' + const [collapsed, setCollapsed] = useState(() => { + if (typeof window === 'undefined') return false + return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === 'true' + }) + const [sites, setSites] = useState([]) + const pathname = usePathname() + + // Close mobile drawer on navigation + useEffect(() => { + onMobileClose() + }, [pathname, onMobileClose]) + + useEffect(() => { + listSites() + .then(setSites) + .catch(() => {}) + }, []) + + const toggleCollapsed = () => { + const next = !collapsed + setCollapsed(next) + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)) + } + + const sidebarContent = (isMobile: boolean) => { + const isCollapsed = isMobile ? false : collapsed + + return ( +
+ {/* Site Picker */} +
+ +
+ + {/* Nav Groups */} + + + {/* Bottom: Settings + Collapse toggle */} +
+ {canEdit && ( + onMobileClose() : undefined} + /> + )} + {!isMobile && ( + + )} +
+
+ ) + } + + return ( + <> + {/* Mobile hamburger trigger — rendered in the header via leftActions */} + + {/* Desktop sidebar */} + + + {/* Mobile overlay drawer */} + {mobileOpen && ( + <> +
onMobileClose()} + /> + + + )} + + ) +} + +export function SidebarMobileToggle({ onClick }: { onClick: () => void }) { + return ( + + ) +} diff --git a/lib/sidebar-context.tsx b/lib/sidebar-context.tsx new file mode 100644 index 0000000..482c3ba --- /dev/null +++ b/lib/sidebar-context.tsx @@ -0,0 +1,31 @@ +'use client' + +import { createContext, useCallback, useContext, useState } from 'react' + +interface SidebarContextValue { + mobileOpen: boolean + openMobile: () => void + closeMobile: () => void +} + +const SidebarContext = createContext({ + mobileOpen: false, + openMobile: () => {}, + closeMobile: () => {}, +}) + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [mobileOpen, setMobileOpen] = useState(false) + const openMobile = useCallback(() => setMobileOpen(true), []) + const closeMobile = useCallback(() => setMobileOpen(false), []) + + return ( + + {children} + + ) +} + +export function useSidebar() { + return useContext(SidebarContext) +} diff --git a/package-lock.json b/package-lock.json index c4243a9..2385d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.15.0-alpha", "dependencies": { - "@ciphera-net/ui": "^0.2.8", + "@ciphera-net/ui": "^0.2.10", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", @@ -1668,9 +1668,9 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.2.8", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.8/3a78342207ee2351625b9469ec6030033df183cc", - "integrity": "sha512-I6B7fE2YXjJaipmcVS60q2pzhsy/NKM4sfvHIv4awi6mcrXjGag8FznW0sI1SbsplFWpoT5iSMtWIi/lZdFhbA==", + "version": "0.2.10", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.2.10/aeae8c3cb25cc9b5193bfba47ce2e444ac82f1d7", + "integrity": "sha512-yWHitk43epGjtwUxGVrKwGYZb+VtJhauy7fgmqYfDC8tq33eVlH+yOdi44J/OiWDl8ONSlt8i5Xptz3k79UuXQ==", "dependencies": { "@phosphor-icons/react": "^2.1.10", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index bf3e35d..635fb5f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ciphera-net/ui": "^0.2.8", + "@ciphera-net/ui": "^0.2.10", "@ducanh2912/next-pwa": "^10.2.9", "@phosphor-icons/react": "^2.1.10", "@simplewebauthn/browser": "^13.2.2", -- 2.49.1 From 61ce505ee53309b86928e92d50815c975619f29a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 18 Mar 2026 15:34:48 +0100 Subject: [PATCH 052/428] fix: pin sidebar to viewport with sticky positioning Sidebar was scrolling with page content. Fix by adding sticky top-0 h-screen. Widen collapsed width to 68px to prevent icon clipping. --- components/dashboard/DashboardShell.tsx | 4 ++-- components/dashboard/Sidebar.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index 0488ba5..a2aeb3c 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -13,9 +13,9 @@ export default function DashboardShell({ const { mobileOpen, closeMobile } = useSidebar() return ( -
+
-
+
{children}
diff --git a/components/dashboard/Sidebar.tsx b/components/dashboard/Sidebar.tsx index 34e9f9c..ad08a1d 100644 --- a/components/dashboard/Sidebar.tsx +++ b/components/dashboard/Sidebar.tsx @@ -319,8 +319,8 @@ export default function Sidebar({ {/* Desktop sidebar */}