mirror of
https://github.com/rybbit-io/rybbit.git
synced 2025-05-10 20:05:38 +02:00
Add goal management functionality in analytics (#100)
* Add goal management functionality in analytics - Introduced goal creation, deletion, and updating capabilities in the analytics API, allowing users to define conversion goals based on path or event types. - Implemented corresponding React hooks for managing goals, including fetching, creating, updating, and deleting goals. - Enhanced the UI with a dedicated Goals page and components for listing and managing goals, improving user experience in tracking conversions. - Updated package dependencies to include necessary libraries for form handling and validation. * Enhance goals management with pagination and sorting - Added pagination and sorting capabilities to the goals fetching logic in the analytics API, allowing users to navigate through goals more efficiently. - Updated the GoalsPage component to manage current page state and handle page changes, improving user experience. - Modified the GoalsList component to display pagination metadata and navigation controls, facilitating better goal management. - Adjusted the server-side getGoals function to support pagination and sorting parameters, ensuring accurate data retrieval. * Refactor GoalsPage and GoalCard components for improved UI and functionality - Updated GoalsPage to include a SubHeader component and adjusted layout for better responsiveness. - Enhanced loading and empty state handling in GoalsPage for a smoother user experience. - Modified GoalCard to use icons for goal types, improving visual clarity and consistency in the UI. * Refactor CreateGoalButton and GoalCard components for improved modal handling - Updated CreateGoalButton to utilize a trigger prop for the GoalFormModal, simplifying modal state management. - Refactored GoalCard to integrate GoalFormModal for editing goals, enhancing user interaction and reducing state complexity. - Removed unnecessary state management and modal handling from both components, streamlining the codebase. * Refactor GoalCard and Clickhouse initialization for improved code clarity - Removed unnecessary imports in GoalCard component, streamlining the code. - Updated Clickhouse initialization to include a new 'props' JSON field alongside 'properties', enhancing data structure for analytics. - Added a utility function in PageviewQueue to parse properties, improving error handling and data integrity. * enable clickhouse * fix ch build * fix ch build * fix ch build * wip * wip * wip * Enable json * add network * add network * Refactor Clickhouse configuration and remove unused properties from data models * Refactor property value handling in analytics queries to utilize native JSON types in ClickHouse, improving type safety and performance.
This commit is contained in:
parent
db1bbe2fb1
commit
ca0faeb484
28 changed files with 2293 additions and 72 deletions
5
clickhouse_config/enable_json.xml
Normal file
5
clickhouse_config/enable_json.xml
Normal file
|
@ -0,0 +1,5 @@
|
|||
<clickhouse>
|
||||
<settings>
|
||||
<enable_json_type>1</enable_json_type>
|
||||
</settings>
|
||||
</clickhouse>
|
|
@ -1,4 +1,3 @@
|
|||
<clickhouse>
|
||||
<listen_host>::</listen_host>
|
||||
<listen_host>0.0.0.0</listen_host>
|
||||
</clickhouse>
|
324
client/package-lock.json
generated
324
client/package-lock.json
generated
|
@ -9,6 +9,7 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@better-auth/stripe": "^1.2.3",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@nivo/bar": "^0.88.0",
|
||||
"@nivo/calendar": "^0.88.0",
|
||||
"@nivo/core": "^0.88.0",
|
||||
|
@ -18,13 +19,13 @@
|
|||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
|
@ -64,6 +65,7 @@
|
|||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-globe.gl": "^2.33.2",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-select": "^5.10.0",
|
||||
"sonner": "^2.0.1",
|
||||
|
@ -71,6 +73,7 @@
|
|||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.175.0",
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -1190,6 +1193,17 @@
|
|||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz",
|
||||
"integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.1",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz",
|
||||
|
@ -2249,6 +2263,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz",
|
||||
|
@ -2401,6 +2432,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
|
@ -2673,23 +2721,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
@ -2885,6 +2916,28 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
|
||||
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz",
|
||||
|
@ -2933,11 +2986,33 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
|
||||
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.4.tgz",
|
||||
"integrity": "sha512-wy3dqizZnZVV4ja0FNnUhIWNwWdoldXrneEyUcVtLYDAt8ovGS4ridtMAOGgXBBIfggL4BOveVWsjXDORdGEQg==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.2"
|
||||
"@radix-ui/react-primitive": "2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
|
||||
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
|
@ -2993,6 +3068,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menubar": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.6.tgz",
|
||||
|
@ -3117,6 +3209,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz",
|
||||
|
@ -3216,6 +3325,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
|
||||
|
@ -3372,6 +3498,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
|
||||
|
@ -3449,11 +3592,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
|
@ -3465,6 +3608,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
|
||||
|
@ -3690,6 +3847,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
|
||||
|
@ -3844,20 +4018,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
|
||||
|
@ -3880,23 +4040,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
|
||||
|
@ -4113,6 +4256,11 @@
|
|||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
|
@ -7948,6 +8096,45 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/radix-ui/node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz",
|
||||
"integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/radix-ui/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/radix-ui/node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",
|
||||
|
@ -8029,6 +8216,21 @@
|
|||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.56.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz",
|
||||
"integrity": "sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
@ -9429,9 +9631,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"version": "3.24.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
|
||||
"integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@better-auth/stripe": "^1.2.3",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@nivo/bar": "^0.88.0",
|
||||
"@nivo/calendar": "^0.88.0",
|
||||
"@nivo/core": "^0.88.0",
|
||||
|
@ -19,13 +20,13 @@
|
|||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.5",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.5",
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
|
@ -65,6 +66,7 @@
|
|||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-globe.gl": "^2.33.2",
|
||||
"react-hook-form": "^7.56.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-select": "^5.10.0",
|
||||
"sonner": "^2.0.1",
|
||||
|
@ -72,6 +74,7 @@
|
|||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.175.0",
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
51
client/src/api/analytics/useCreateGoal.ts
Normal file
51
client/src/api/analytics/useCreateGoal.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BACKEND_URL } from "../../lib/const";
|
||||
import { authedFetchWithError } from "../utils";
|
||||
|
||||
export interface CreateGoalRequest {
|
||||
siteId: number;
|
||||
name?: string;
|
||||
goalType: "path" | "event";
|
||||
config: {
|
||||
pathPattern?: string;
|
||||
eventName?: string;
|
||||
eventPropertyKey?: string;
|
||||
eventPropertyValue?: string | number | boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateGoalResponse {
|
||||
success: boolean;
|
||||
goalId: number;
|
||||
}
|
||||
|
||||
export function useCreateGoal() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<CreateGoalResponse, Error, CreateGoalRequest>({
|
||||
mutationFn: async (goalData) => {
|
||||
try {
|
||||
return await authedFetchWithError<CreateGoalResponse>(
|
||||
`${BACKEND_URL}/goal/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(goalData),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error ? error.message : "Failed to create goal"
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate goals query to refetch with the new goal
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["goals", variables.siteId.toString()],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
32
client/src/api/analytics/useDeleteGoal.ts
Normal file
32
client/src/api/analytics/useDeleteGoal.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BACKEND_URL } from "../../lib/const";
|
||||
import { authedFetchWithError } from "../utils";
|
||||
import { useStore } from "../../lib/store";
|
||||
|
||||
export function useDeleteGoal() {
|
||||
const queryClient = useQueryClient();
|
||||
const { site } = useStore();
|
||||
|
||||
return useMutation<{ success: boolean }, Error, number>({
|
||||
mutationFn: async (goalId: number) => {
|
||||
try {
|
||||
return await authedFetchWithError<{ success: boolean }>(
|
||||
`${BACKEND_URL}/goal/${goalId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error ? error.message : "Failed to delete goal"
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate goals query to refetch without the deleted goal
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["goals", site],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
39
client/src/api/analytics/useGetGoal.ts
Normal file
39
client/src/api/analytics/useGetGoal.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BACKEND_URL } from "../../lib/const";
|
||||
import { authedFetch } from "../utils";
|
||||
import { useStore, Filter } from "../../lib/store";
|
||||
import { Goal } from "./useGetGoals";
|
||||
|
||||
interface GetGoalResponse {
|
||||
data: Goal;
|
||||
}
|
||||
|
||||
export function useGetGoal({
|
||||
goalId,
|
||||
startDate,
|
||||
endDate,
|
||||
filters,
|
||||
enabled = true,
|
||||
}: {
|
||||
goalId: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
filters?: Filter[];
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { site } = useStore();
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["goal", site, goalId, startDate, endDate, timezone, filters],
|
||||
queryFn: async () => {
|
||||
return authedFetch(`${BACKEND_URL}/goal/${goalId}/${site}`, {
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
filters,
|
||||
}).then((res) => res.json());
|
||||
},
|
||||
enabled: !!site && !!goalId && enabled,
|
||||
});
|
||||
}
|
83
client/src/api/analytics/useGetGoals.ts
Normal file
83
client/src/api/analytics/useGetGoals.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BACKEND_URL } from "../../lib/const";
|
||||
import { authedFetch } from "../utils";
|
||||
import { useStore, Filter } from "../../lib/store";
|
||||
|
||||
export interface Goal {
|
||||
goalId: number;
|
||||
name: string | null;
|
||||
goalType: "path" | "event";
|
||||
config: {
|
||||
pathPattern?: string;
|
||||
eventName?: string;
|
||||
eventPropertyKey?: string;
|
||||
eventPropertyValue?: string | number | boolean;
|
||||
};
|
||||
createdAt: string;
|
||||
total_conversions: number;
|
||||
total_sessions: number;
|
||||
conversion_rate: number;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface GoalsResponse {
|
||||
data: Goal[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export function useGetGoals({
|
||||
startDate,
|
||||
endDate,
|
||||
filters,
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
sort = "createdAt",
|
||||
order = "desc",
|
||||
enabled = true,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
filters?: Filter[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: "goalId" | "name" | "goalType" | "createdAt";
|
||||
order?: "asc" | "desc";
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { site } = useStore();
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"goals",
|
||||
site,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
order,
|
||||
],
|
||||
queryFn: async () => {
|
||||
return authedFetch(`${BACKEND_URL}/goals/${site}`, {
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
order,
|
||||
}).then((res) => res.json());
|
||||
},
|
||||
enabled: !!site && enabled,
|
||||
});
|
||||
}
|
54
client/src/api/analytics/useUpdateGoal.ts
Normal file
54
client/src/api/analytics/useUpdateGoal.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BACKEND_URL } from "../../lib/const";
|
||||
import { authedFetchWithError } from "../utils";
|
||||
import { useStore } from "../../lib/store";
|
||||
|
||||
export interface UpdateGoalRequest {
|
||||
goalId: number;
|
||||
siteId: number;
|
||||
name?: string;
|
||||
goalType: "path" | "event";
|
||||
config: {
|
||||
pathPattern?: string;
|
||||
eventName?: string;
|
||||
eventPropertyKey?: string;
|
||||
eventPropertyValue?: string | number | boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateGoalResponse {
|
||||
success: boolean;
|
||||
goalId: number;
|
||||
}
|
||||
|
||||
export function useUpdateGoal() {
|
||||
const queryClient = useQueryClient();
|
||||
const { site } = useStore();
|
||||
|
||||
return useMutation<UpdateGoalResponse, Error, UpdateGoalRequest>({
|
||||
mutationFn: async (goalData) => {
|
||||
try {
|
||||
return await authedFetchWithError<UpdateGoalResponse>(
|
||||
`${BACKEND_URL}/goal/update`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(goalData),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
error instanceof Error ? error.message : "Failed to update goal"
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate goals query to refetch with the updated goal
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["goals", site],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { Funnel } from "@phosphor-icons/react/dist/ssr";
|
||||
import { Funnel, Target } from "@phosphor-icons/react/dist/ssr";
|
||||
import {
|
||||
ChartBarDecreasing,
|
||||
Earth,
|
||||
|
@ -88,6 +88,12 @@ export function Sidebar() {
|
|||
href={getTabPath("funnels")}
|
||||
icon={<Funnel weight="bold" />}
|
||||
/>
|
||||
<SidebarLink
|
||||
label="Goals"
|
||||
active={isActiveTab("goals")}
|
||||
href={getTabPath("goals")}
|
||||
icon={<Target weight="bold" />}
|
||||
/>
|
||||
<SidebarLink
|
||||
label="Journeys"
|
||||
active={isActiveTab("journeys")}
|
||||
|
|
25
client/src/app/[site]/goals/components/CreateGoalButton.tsx
Normal file
25
client/src/app/[site]/goals/components/CreateGoalButton.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import GoalFormModal from "./GoalFormModal";
|
||||
|
||||
interface CreateGoalButtonProps {
|
||||
siteId: number;
|
||||
}
|
||||
|
||||
export default function CreateGoalButton({ siteId }: CreateGoalButtonProps) {
|
||||
return (
|
||||
<>
|
||||
<GoalFormModal
|
||||
siteId={siteId}
|
||||
trigger={
|
||||
<Button className="flex items-center gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Goal</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
156
client/src/app/[site]/goals/components/GoalCard.tsx
Normal file
156
client/src/app/[site]/goals/components/GoalCard.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, MousePointerClick, Pencil, Trash } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useDeleteGoal } from "../../../../api/analytics/useDeleteGoal";
|
||||
import { Goal } from "../../../../api/analytics/useGetGoals";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../../../../components/ui/alert-dialog";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../../components/ui/card";
|
||||
import GoalFormModal from "./GoalFormModal";
|
||||
|
||||
interface GoalCardProps {
|
||||
goal: Goal;
|
||||
siteId: number;
|
||||
}
|
||||
|
||||
export default function GoalCard({ goal, siteId }: GoalCardProps) {
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const deleteGoalMutation = useDeleteGoal();
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteGoalMutation.mutateAsync(goal.goalId);
|
||||
setIsDeleteDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error deleting goal:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">
|
||||
{goal.name || `Goal #${goal.goalId}`}
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<GoalFormModal
|
||||
siteId={siteId}
|
||||
goal={goal}
|
||||
trigger={
|
||||
<Button variant="ghost" size="smIcon">
|
||||
<Pencil />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className=""
|
||||
variant="ghost"
|
||||
size="smIcon"
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-2 flex-grow">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-gray-500 mr-2">Type:</span>
|
||||
{goal.goalType === "path" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm">Path Goal</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<MousePointerClick className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm">Event Goal</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Pattern:</span>
|
||||
<div className="mt-1 text-sm truncate">
|
||||
{goal.goalType === "path" ? (
|
||||
<code className="bg-neutral-800/50 px-1 py-0.5 rounded">
|
||||
{goal.config.pathPattern}
|
||||
</code>
|
||||
) : (
|
||||
<code className="bg-neutral-800/50 px-1 py-0.5 rounded">
|
||||
{goal.config.eventName}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{goal.goalType === "event" && goal.config.eventPropertyKey && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Property:{" "}
|
||||
<code className="bg-neutral-800/50 px-1 py-0.5 rounded">
|
||||
{goal.config.eventPropertyKey} ={" "}
|
||||
{String(goal.config.eventPropertyValue)}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="grid grid-cols-2 gap-2 p-4 mt-auto bg-neutral-800/20 rounded-b-lg">
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-lg">{goal.total_conversions}</div>
|
||||
<div className="text-xs text-gray-500">Conversions</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-lg">
|
||||
{(goal.conversion_rate * 100).toFixed(2)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Conversion Rate</div>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete this goal?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
goal and remove it from all reports.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} variant="destructive">
|
||||
{deleteGoalMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
336
client/src/app/[site]/goals/components/GoalFormModal.tsx
Normal file
336
client/src/app/[site]/goals/components/GoalFormModal.tsx
Normal file
|
@ -0,0 +1,336 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../../../components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../../../../components/ui/form";
|
||||
import { Input } from "../../../../components/ui/input";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../../components/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Goal } from "../../../../api/analytics/useGetGoals";
|
||||
import { useCreateGoal } from "../../../../api/analytics/useCreateGoal";
|
||||
import { useUpdateGoal } from "../../../../api/analytics/useUpdateGoal";
|
||||
import { Switch } from "../../../../components/ui/switch";
|
||||
import { Label } from "../../../../components/ui/label";
|
||||
|
||||
// Define form schema
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
goalType: z.enum(["path", "event"]),
|
||||
config: z.object({
|
||||
pathPattern: z.string().optional(),
|
||||
eventName: z.string().optional(),
|
||||
eventPropertyKey: z.string().optional(),
|
||||
eventPropertyValue: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.goalType === "path") {
|
||||
return !!data.config.pathPattern;
|
||||
} else if (data.goalType === "event") {
|
||||
return !!data.config.eventName;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message: "Configuration is required based on goal type",
|
||||
path: ["config"],
|
||||
}
|
||||
);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface GoalFormModalProps {
|
||||
siteId: number;
|
||||
goal?: Goal; // Optional goal for editing mode
|
||||
trigger: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function GoalFormModal({
|
||||
siteId,
|
||||
goal,
|
||||
trigger,
|
||||
}: GoalFormModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [useProperties, setUseProperties] = useState(
|
||||
!!goal?.config.eventPropertyKey && !!goal?.config.eventPropertyValue
|
||||
);
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const isEditMode = !!goal;
|
||||
const createGoal = useCreateGoal();
|
||||
const updateGoal = useUpdateGoal();
|
||||
|
||||
// Initialize form with default values or existing goal
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: isEditMode
|
||||
? {
|
||||
name: goal.name || "",
|
||||
goalType: goal.goalType,
|
||||
config: {
|
||||
pathPattern: goal.config.pathPattern || "",
|
||||
eventName: goal.config.eventName || "",
|
||||
eventPropertyKey: goal.config.eventPropertyKey || "",
|
||||
eventPropertyValue:
|
||||
goal.config.eventPropertyValue !== undefined
|
||||
? String(goal.config.eventPropertyValue)
|
||||
: "",
|
||||
},
|
||||
}
|
||||
: {
|
||||
name: "",
|
||||
goalType: "path",
|
||||
config: {
|
||||
pathPattern: "",
|
||||
eventName: "",
|
||||
eventPropertyKey: "",
|
||||
eventPropertyValue: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const goalType = form.watch("goalType");
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
// Clean up the config based on goal type
|
||||
if (values.goalType === "path") {
|
||||
values.config.eventName = undefined;
|
||||
values.config.eventPropertyKey = undefined;
|
||||
values.config.eventPropertyValue = undefined;
|
||||
} else if (values.goalType === "event") {
|
||||
values.config.pathPattern = undefined;
|
||||
|
||||
// If not using properties, clear them
|
||||
if (!useProperties) {
|
||||
values.config.eventPropertyKey = undefined;
|
||||
values.config.eventPropertyValue = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
await updateGoal.mutateAsync({
|
||||
goalId: goal.goalId,
|
||||
siteId,
|
||||
name: values.name,
|
||||
goalType: values.goalType,
|
||||
config: values.config,
|
||||
});
|
||||
} else {
|
||||
await createGoal.mutateAsync({
|
||||
siteId,
|
||||
name: values.name,
|
||||
goalType: values.goalType,
|
||||
config: values.config,
|
||||
});
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving goal:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "Edit Goal" : "Create Goal"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditMode
|
||||
? "Update the goal details below."
|
||||
: "Set up a new conversion goal to track specific user actions."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Goal Name (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Sign Up Completion" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goalType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Goal Type</FormLabel>
|
||||
<Select
|
||||
disabled={isEditMode} // Don't allow changing goal type in edit mode
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a goal type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="path">Path Goal</SelectItem>
|
||||
<SelectItem value="event">Event Goal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{goalType === "path" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.pathPattern"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Path Pattern</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="/checkout/complete or /product/*/view"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Use * to match a single path segment. Use ** to match
|
||||
across segments.
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{goalType === "event" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.eventName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Event Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g., sign_up_completed"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Switch
|
||||
id="use-properties"
|
||||
checked={useProperties}
|
||||
onCheckedChange={setUseProperties}
|
||||
/>
|
||||
<Label htmlFor="use-properties">
|
||||
Match specific event property
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{useProperties && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.eventPropertyKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Property Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., plan_type" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.eventPropertyValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Property Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., premium" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createGoal.isPending || updateGoal.isPending}
|
||||
variant="success"
|
||||
>
|
||||
{createGoal.isPending || updateGoal.isPending
|
||||
? "Saving..."
|
||||
: isEditMode
|
||||
? "Update"
|
||||
: "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
75
client/src/app/[site]/goals/components/GoalsList.tsx
Normal file
75
client/src/app/[site]/goals/components/GoalsList.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { Goal, PaginationMeta } from "../../../../api/analytics/useGetGoals";
|
||||
import GoalCard from "./GoalCard";
|
||||
import { Button } from "../../../../components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface GoalsListProps {
|
||||
goals: Goal[];
|
||||
siteId: number;
|
||||
paginationMeta: PaginationMeta;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function GoalsList({
|
||||
goals,
|
||||
siteId,
|
||||
paginationMeta,
|
||||
onPageChange,
|
||||
}: GoalsListProps) {
|
||||
const { page, pageSize, total, totalPages } = paginationMeta;
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (page > 1) {
|
||||
onPageChange(page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (page < totalPages) {
|
||||
onPageChange(page + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{goals.map((goal) => (
|
||||
<GoalCard key={goal.goalId} goal={goal} siteId={siteId} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-500">
|
||||
Showing {goals.length} of {total} goals
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<div className="text-sm px-3">
|
||||
Page {page} of {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
70
client/src/app/[site]/goals/page.tsx
Normal file
70
client/src/app/[site]/goals/page.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useGetGoals } from "../../../api/analytics/useGetGoals";
|
||||
import { getStartAndEndDate } from "../../../api/utils";
|
||||
import { SESSION_PAGE_FILTERS, useStore } from "../../../lib/store";
|
||||
import { SubHeader } from "../components/SubHeader/SubHeader";
|
||||
import CreateGoalButton from "./components/CreateGoalButton";
|
||||
import GoalsList from "./components/GoalsList";
|
||||
|
||||
export default function GoalsPage() {
|
||||
const { time, filters, site, setTime } = useStore();
|
||||
const { startDate, endDate } = getStartAndEndDate(time);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 9; // Show 9 cards (3x3 grid)
|
||||
|
||||
// Handle the case where startDate or endDate might be null (for 'all-time' mode)
|
||||
const queryStartDate = startDate || "2020-01-01"; // Default fallback date
|
||||
const queryEndDate = endDate || new Date().toISOString().split("T")[0]; // Today
|
||||
|
||||
// Fetch goals data with pagination
|
||||
const { data: goalsData, isLoading } = useGetGoals({
|
||||
startDate: queryStartDate,
|
||||
endDate: queryEndDate,
|
||||
filters,
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
// Scroll to top of page when changing pages
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 md:p-4 max-w-[1400px] mx-auto space-y-3">
|
||||
<SubHeader availableFilters={SESSION_PAGE_FILTERS} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div />
|
||||
<CreateGoalButton siteId={Number(site)} />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-pulse">Loading goals data...</div>
|
||||
</div>
|
||||
) : !goalsData || goalsData.data.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<h3 className="text-lg font-medium">No goals configured yet</h3>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Create your first conversion goal to start tracking important user
|
||||
actions.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<GoalsList
|
||||
goals={goalsData.data}
|
||||
siteId={Number(site)}
|
||||
paginationMeta={goalsData.meta}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
178
client/src/components/ui/form.tsx
Normal file
178
client/src/components/ui/form.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-red-500 dark:text-red-900", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-neutral-500 dark:text-neutral-400", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-red-500 dark:text-red-900", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
|
@ -27,8 +27,7 @@ services:
|
|||
- "9000:9000"
|
||||
volumes:
|
||||
- clickhouse-data:/var/lib/clickhouse
|
||||
# Reduce clickhouse logging
|
||||
# - ./clickhouse_config:/etc/clickhouse-server/config.d
|
||||
- ./clickhouse_config:/etc/clickhouse-server/config.d
|
||||
environment:
|
||||
- CLICKHOUSE_DB=analytics
|
||||
- CLICKHOUSE_USER=default
|
||||
|
|
|
@ -27,7 +27,6 @@ services:
|
|||
- "9000:9000"
|
||||
volumes:
|
||||
- clickhouse-data:/var/lib/clickhouse
|
||||
# Reduce clickhouse logging
|
||||
- ./clickhouse_config:/etc/clickhouse-server/config.d
|
||||
environment:
|
||||
- CLICKHOUSE_DB=analytics
|
||||
|
|
133
server/src/api/analytics/createGoal.ts
Normal file
133
server/src/api/analytics/createGoal.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../../db/postgres/postgres.js";
|
||||
import { goals } from "../../db/postgres/schema.js";
|
||||
import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
|
||||
import { z } from "zod";
|
||||
|
||||
// Define validation schema for path pattern
|
||||
const pathPatternSchema = z.string().min(1, "Path pattern cannot be empty");
|
||||
|
||||
// Define validation schema for event config
|
||||
const eventConfigSchema = z
|
||||
.object({
|
||||
eventName: z.string().min(1, "Event name cannot be empty"),
|
||||
eventPropertyKey: z.string().optional(),
|
||||
eventPropertyValue: z
|
||||
.union([z.string(), z.number(), z.boolean()])
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// If one property matching field is provided, both must be provided
|
||||
if (data.eventPropertyKey && data.eventPropertyValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (data.eventPropertyValue !== undefined && !data.eventPropertyKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Both eventPropertyKey and eventPropertyValue must be provided together or omitted together",
|
||||
}
|
||||
);
|
||||
|
||||
// Define validation schema for the goal request
|
||||
const goalSchema = z
|
||||
.object({
|
||||
siteId: z.number().int().positive("Site ID must be a positive integer"),
|
||||
name: z.string().optional(),
|
||||
goalType: z.enum(["path", "event"]),
|
||||
config: z.object({
|
||||
pathPattern: z.string().optional(),
|
||||
eventName: z.string().optional(),
|
||||
eventPropertyKey: z.string().optional(),
|
||||
eventPropertyValue: z
|
||||
.union([z.string(), z.number(), z.boolean()])
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.goalType === "path") {
|
||||
return !!data.config.pathPattern;
|
||||
} else if (data.goalType === "event") {
|
||||
return !!data.config.eventName;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message: "Configuration must match goal type",
|
||||
path: ["config"],
|
||||
}
|
||||
);
|
||||
|
||||
type CreateGoalRequest = z.infer<typeof goalSchema>;
|
||||
|
||||
export async function createGoal(
|
||||
request: FastifyRequest<{
|
||||
Body: CreateGoalRequest;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// Validate the request body
|
||||
const validatedData = goalSchema.parse(request.body);
|
||||
const { siteId, name, goalType, config } = validatedData;
|
||||
|
||||
// Check user access to site
|
||||
const userHasAccessToSite = await getUserHasAccessToSite(
|
||||
request,
|
||||
siteId.toString()
|
||||
);
|
||||
if (!userHasAccessToSite) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
// Additional validation based on goal type
|
||||
if (goalType === "path") {
|
||||
// Validate path pattern
|
||||
pathPatternSchema.parse(config.pathPattern);
|
||||
} else if (goalType === "event") {
|
||||
// Validate event configuration
|
||||
eventConfigSchema.parse({
|
||||
eventName: config.eventName,
|
||||
eventPropertyKey: config.eventPropertyKey,
|
||||
eventPropertyValue: config.eventPropertyValue,
|
||||
});
|
||||
}
|
||||
|
||||
// Insert the goal into the database
|
||||
const result = await db
|
||||
.insert(goals)
|
||||
.values({
|
||||
siteId,
|
||||
name: name || null, // Use null if name is not provided
|
||||
goalType,
|
||||
config,
|
||||
})
|
||||
.returning({ goalId: goals.goalId });
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return reply.status(500).send({ error: "Failed to create goal" });
|
||||
}
|
||||
|
||||
return reply.status(201).send({
|
||||
success: true,
|
||||
goalId: result[0].goalId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating goal:", error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.status(400).send({
|
||||
error: "Validation error",
|
||||
details: error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({ error: "Failed to create goal" });
|
||||
}
|
||||
}
|
52
server/src/api/analytics/deleteGoal.ts
Normal file
52
server/src/api/analytics/deleteGoal.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../../db/postgres/postgres.js";
|
||||
import { goals } from "../../db/postgres/schema.js";
|
||||
import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function deleteGoal(
|
||||
request: FastifyRequest<{
|
||||
Params: {
|
||||
goalId: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { goalId } = request.params;
|
||||
|
||||
try {
|
||||
// Get the goal to check the site ID
|
||||
const goalToDelete = await db.query.goals.findFirst({
|
||||
where: eq(goals.goalId, parseInt(goalId, 10)),
|
||||
});
|
||||
|
||||
if (!goalToDelete) {
|
||||
return reply.status(404).send({ error: "Goal not found" });
|
||||
}
|
||||
|
||||
// Check user access to the site
|
||||
const userHasAccessToSite = await getUserHasAccessToSite(
|
||||
request,
|
||||
goalToDelete.siteId.toString()
|
||||
);
|
||||
|
||||
if (!userHasAccessToSite) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
// Delete the goal
|
||||
const result = await db
|
||||
.delete(goals)
|
||||
.where(eq(goals.goalId, parseInt(goalId, 10)))
|
||||
.returning({ deleted: goals.goalId });
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return reply.status(500).send({ error: "Failed to delete goal" });
|
||||
}
|
||||
|
||||
return reply.send({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting goal:", error);
|
||||
return reply.status(500).send({ error: "Failed to delete goal" });
|
||||
}
|
||||
}
|
195
server/src/api/analytics/getGoal.ts
Normal file
195
server/src/api/analytics/getGoal.ts
Normal file
|
@ -0,0 +1,195 @@
|
|||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../../db/postgres/postgres.js";
|
||||
import { goals } from "../../db/postgres/schema.js";
|
||||
import clickhouse from "../../db/clickhouse/clickhouse.js";
|
||||
import { getUserHasAccessToSitePublic } from "../../lib/auth-utils.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
getFilterStatement,
|
||||
getTimeStatement,
|
||||
processResults,
|
||||
} from "./utils.js";
|
||||
import SqlString from "sqlstring";
|
||||
|
||||
// Helper to convert wildcard patterns to ClickHouse regex (same as in getGoals.ts)
|
||||
function patternToRegex(pattern: string): string {
|
||||
// Escape special regex characters except * which we'll handle specially
|
||||
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// Replace ** with a temporary marker
|
||||
const withDoubleStar = escapedPattern.replace(/\*\*/g, "{{DOUBLE_STAR}}");
|
||||
|
||||
// Replace * with [^/]+ (any characters except /)
|
||||
const withSingleStar = withDoubleStar.replace(/\*/g, "[^/]+");
|
||||
|
||||
// Replace the double star marker with .* (any characters including /)
|
||||
const finalRegex = withSingleStar.replace(/{{DOUBLE_STAR}}/g, ".*");
|
||||
|
||||
// Anchor the regex to start/end of string for exact matches
|
||||
return `^${finalRegex}$`;
|
||||
}
|
||||
|
||||
export async function getGoal(
|
||||
request: FastifyRequest<{
|
||||
Params: {
|
||||
goalId: string;
|
||||
site: string;
|
||||
};
|
||||
Querystring: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timezone: string;
|
||||
filters?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { goalId, site } = request.params;
|
||||
const { startDate, endDate, timezone, filters } = request.query;
|
||||
|
||||
// Check user access to site
|
||||
const userHasAccessToSite = await getUserHasAccessToSitePublic(request, site);
|
||||
if (!userHasAccessToSite) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the goal from PostgreSQL
|
||||
const goal = await db.query.goals.findFirst({
|
||||
where: eq(goals.goalId, parseInt(goalId, 10)),
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
return reply.status(404).send({ error: "Goal not found" });
|
||||
}
|
||||
|
||||
// Ensure the goal belongs to the specified site
|
||||
if (goal.siteId !== parseInt(site, 10)) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Goal does not belong to the specified site" });
|
||||
}
|
||||
|
||||
// Build filter and time clauses for ClickHouse queries
|
||||
const filterStatement = filters ? getFilterStatement(filters) : "";
|
||||
const timeStatement = getTimeStatement({
|
||||
date: { startDate, endDate, timezone },
|
||||
});
|
||||
|
||||
// First, get the total number of unique sessions (denominator for conversion rate)
|
||||
const totalSessionsQuery = `
|
||||
SELECT COUNT(DISTINCT session_id) AS total_sessions
|
||||
FROM events
|
||||
WHERE site_id = ${SqlString.escape(Number(site))}
|
||||
${timeStatement}
|
||||
${filterStatement}
|
||||
`;
|
||||
|
||||
const totalSessionsResult = await clickhouse.query({
|
||||
query: totalSessionsQuery,
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
const totalSessionsData = await processResults<{ total_sessions: number }>(
|
||||
totalSessionsResult
|
||||
);
|
||||
const totalSessions = totalSessionsData[0]?.total_sessions || 0;
|
||||
|
||||
// Build a query specific to this goal type to calculate conversions
|
||||
let conversionQuery = "";
|
||||
|
||||
if (goal.goalType === "path") {
|
||||
const pathPattern = goal.config.pathPattern;
|
||||
if (!pathPattern) {
|
||||
return reply.status(400).send({ error: "Invalid goal configuration" });
|
||||
}
|
||||
|
||||
const regex = patternToRegex(pathPattern);
|
||||
conversionQuery = `
|
||||
SELECT COUNT(DISTINCT session_id) AS total_conversions
|
||||
FROM events
|
||||
WHERE site_id = ${SqlString.escape(Number(site))}
|
||||
AND type = 'pageview'
|
||||
AND match(pathname, ${SqlString.escape(regex)})
|
||||
${timeStatement}
|
||||
${filterStatement}
|
||||
`;
|
||||
} else if (goal.goalType === "event") {
|
||||
const eventName = goal.config.eventName;
|
||||
const eventPropertyKey = goal.config.eventPropertyKey;
|
||||
const eventPropertyValue = goal.config.eventPropertyValue;
|
||||
|
||||
if (!eventName) {
|
||||
return reply.status(400).send({ error: "Invalid goal configuration" });
|
||||
}
|
||||
|
||||
let eventClause = `type = 'custom_event' AND event_name = ${SqlString.escape(
|
||||
eventName
|
||||
)}`;
|
||||
|
||||
// Add property matching if needed
|
||||
if (eventPropertyKey && eventPropertyValue !== undefined) {
|
||||
// Access the sub-column directly for native JSON type
|
||||
const propValueAccessor = `props.${SqlString.escapeId(
|
||||
eventPropertyKey
|
||||
)}`;
|
||||
|
||||
// Comparison needs to handle the Dynamic type returned
|
||||
// Let ClickHouse handle the comparison based on the provided value type
|
||||
if (typeof eventPropertyValue === "string") {
|
||||
eventClause += ` AND toString(${propValueAccessor}) = ${SqlString.escape(
|
||||
eventPropertyValue
|
||||
)}`;
|
||||
} else if (typeof eventPropertyValue === "number") {
|
||||
// Use toFloat64 or toInt* depending on expected number type
|
||||
eventClause += ` AND toFloat64OrNull(${propValueAccessor}) = ${SqlString.escape(
|
||||
eventPropertyValue
|
||||
)}`;
|
||||
} else if (typeof eventPropertyValue === "boolean") {
|
||||
// Booleans might be stored as 0/1 or true/false in JSON
|
||||
// Comparing toUInt8 seems robust
|
||||
eventClause += ` AND toUInt8OrNull(${propValueAccessor}) = ${
|
||||
eventPropertyValue ? 1 : 0
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
conversionQuery = `
|
||||
SELECT COUNT(DISTINCT session_id) AS total_conversions
|
||||
FROM events
|
||||
WHERE site_id = ${SqlString.escape(Number(site))}
|
||||
AND ${eventClause}
|
||||
${timeStatement}
|
||||
${filterStatement}
|
||||
`;
|
||||
} else {
|
||||
return reply.status(400).send({ error: "Invalid goal type" });
|
||||
}
|
||||
|
||||
// Execute the conversion query
|
||||
const conversionResult = await clickhouse.query({
|
||||
query: conversionQuery,
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
const conversionData = await processResults<{ total_conversions: number }>(
|
||||
conversionResult
|
||||
);
|
||||
const totalConversions = conversionData[0]?.total_conversions || 0;
|
||||
const conversionRate =
|
||||
totalSessions > 0 ? totalConversions / totalSessions : 0;
|
||||
|
||||
// Return the goal with conversion metrics
|
||||
return reply.send({
|
||||
data: {
|
||||
...goal,
|
||||
total_conversions: totalConversions,
|
||||
total_sessions: totalSessions,
|
||||
conversion_rate: conversionRate,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching goal:", error);
|
||||
return reply.status(500).send({ error: "Failed to fetch goal data" });
|
||||
}
|
||||
}
|
329
server/src/api/analytics/getGoals.ts
Normal file
329
server/src/api/analytics/getGoals.ts
Normal file
|
@ -0,0 +1,329 @@
|
|||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../../db/postgres/postgres.js";
|
||||
import { goals } from "../../db/postgres/schema.js";
|
||||
import clickhouse from "../../db/clickhouse/clickhouse.js";
|
||||
import { getUserHasAccessToSitePublic } from "../../lib/auth-utils.js";
|
||||
import { eq, desc, asc, sql } from "drizzle-orm";
|
||||
import {
|
||||
getFilterStatement,
|
||||
getTimeStatement,
|
||||
processResults,
|
||||
} from "./utils.js";
|
||||
import SqlString from "sqlstring";
|
||||
|
||||
// Helper to convert wildcard patterns to ClickHouse regex
|
||||
function patternToRegex(pattern: string): string {
|
||||
// Escape special regex characters except * which we'll handle specially
|
||||
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
// Replace ** with a temporary marker
|
||||
const withDoubleStar = escapedPattern.replace(/\*\*/g, "{{DOUBLE_STAR}}");
|
||||
|
||||
// Replace * with [^/]+ (any characters except /)
|
||||
const withSingleStar = withDoubleStar.replace(/\*/g, "[^/]+");
|
||||
|
||||
// Replace the double star marker with .* (any characters including /)
|
||||
const finalRegex = withSingleStar.replace(/{{DOUBLE_STAR}}/g, ".*");
|
||||
|
||||
// Anchor the regex to start/end of string for exact matches
|
||||
return `^${finalRegex}$`;
|
||||
}
|
||||
|
||||
// Types for the response
|
||||
interface GoalWithConversions {
|
||||
goalId: number;
|
||||
name: string | null;
|
||||
goalType: string;
|
||||
config: any;
|
||||
createdAt: string | null;
|
||||
total_conversions: number;
|
||||
total_sessions: number;
|
||||
conversion_rate: number;
|
||||
}
|
||||
|
||||
interface GetGoalsResponse {
|
||||
data: GoalWithConversions[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGoals(
|
||||
request: FastifyRequest<{
|
||||
Params: {
|
||||
site: string;
|
||||
};
|
||||
Querystring: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timezone: string;
|
||||
filters?: string;
|
||||
page?: string;
|
||||
pageSize?: string;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { site } = request.params;
|
||||
const {
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
filters,
|
||||
page = "1",
|
||||
pageSize = "10",
|
||||
sort = "createdAt",
|
||||
order = "desc",
|
||||
} = request.query;
|
||||
|
||||
const pageNumber = parseInt(page, 10);
|
||||
const pageSizeNumber = parseInt(pageSize, 10);
|
||||
|
||||
// Validate page and pageSize
|
||||
if (isNaN(pageNumber) || pageNumber < 1) {
|
||||
return reply.status(400).send({ error: "Invalid page number" });
|
||||
}
|
||||
|
||||
if (isNaN(pageSizeNumber) || pageSizeNumber < 1 || pageSizeNumber > 100) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid page size, must be between 1 and 100" });
|
||||
}
|
||||
|
||||
// Check user access to site
|
||||
const userHasAccessToSite = await getUserHasAccessToSitePublic(request, site);
|
||||
if (!userHasAccessToSite) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Count total goals for pagination metadata
|
||||
const totalGoalsResult = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(goals)
|
||||
.where(eq(goals.siteId, Number(site)));
|
||||
|
||||
const totalGoals = totalGoalsResult[0]?.count || 0;
|
||||
const totalPages = Math.ceil(totalGoals / pageSizeNumber);
|
||||
|
||||
// If no goals exist, return early with empty data
|
||||
if (totalGoals === 0) {
|
||||
return reply.send({
|
||||
data: [],
|
||||
meta: {
|
||||
total: 0,
|
||||
page: pageNumber,
|
||||
pageSize: pageSizeNumber,
|
||||
totalPages: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
let orderBy;
|
||||
// Only allow sorting by valid columns
|
||||
const validSortColumns = ["goalId", "name", "goalType", "createdAt"];
|
||||
const sortColumn = validSortColumns.includes(sort) ? sort : "createdAt";
|
||||
|
||||
if (order === "asc") {
|
||||
if (sortColumn === "goalId") orderBy = asc(goals.goalId);
|
||||
else if (sortColumn === "name") orderBy = asc(goals.name);
|
||||
else if (sortColumn === "goalType") orderBy = asc(goals.goalType);
|
||||
else orderBy = asc(goals.createdAt);
|
||||
} else {
|
||||
if (sortColumn === "goalId") orderBy = desc(goals.goalId);
|
||||
else if (sortColumn === "name") orderBy = desc(goals.name);
|
||||
else if (sortColumn === "goalType") orderBy = desc(goals.goalType);
|
||||
else orderBy = desc(goals.createdAt);
|
||||
}
|
||||
|
||||
// Fetch paginated goals for this site from PostgreSQL
|
||||
const siteGoals = await db
|
||||
.select()
|
||||
.from(goals)
|
||||
.where(eq(goals.siteId, Number(site)))
|
||||
.orderBy(orderBy)
|
||||
.limit(pageSizeNumber)
|
||||
.offset((pageNumber - 1) * pageSizeNumber);
|
||||
|
||||
if (siteGoals.length === 0) {
|
||||
// If no goals for this page, return empty data
|
||||
return reply.send({
|
||||
data: [],
|
||||
meta: {
|
||||
total: totalGoals,
|
||||
page: pageNumber,
|
||||
pageSize: pageSizeNumber,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build filter and time clauses for ClickHouse queries
|
||||
const filterStatement = filters ? getFilterStatement(filters) : "";
|
||||
const timeStatement = getTimeStatement({
|
||||
date: { startDate, endDate, timezone },
|
||||
});
|
||||
|
||||
// First, get the total number of unique sessions (denominator for conversion rate)
|
||||
const totalSessionsQuery = `
|
||||
SELECT COUNT(DISTINCT session_id) AS total_sessions
|
||||
FROM events
|
||||
WHERE site_id = ${SqlString.escape(Number(site))}
|
||||
${timeStatement}
|
||||
${filterStatement}
|
||||
`;
|
||||
|
||||
const totalSessionsResult = await clickhouse.query({
|
||||
query: totalSessionsQuery,
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
const totalSessionsData = await processResults<{ total_sessions: number }>(
|
||||
totalSessionsResult
|
||||
);
|
||||
const totalSessions = totalSessionsData[0]?.total_sessions || 0;
|
||||
|
||||
// Build a single query that calculates all goal conversions at once using conditional aggregation
|
||||
// This is more efficient than separate queries for each goal
|
||||
let conditionalClauses: string[] = [];
|
||||
|
||||
for (const goal of siteGoals) {
|
||||
if (goal.goalType === "path") {
|
||||
const pathPattern = goal.config.pathPattern;
|
||||
if (!pathPattern) continue;
|
||||
|
||||
const regex = patternToRegex(pathPattern);
|
||||
conditionalClauses.push(`
|
||||
COUNT(DISTINCT IF(
|
||||
type = 'pageview' AND match(pathname, ${SqlString.escape(regex)}),
|
||||
session_id,
|
||||
NULL
|
||||
)) AS goal_${goal.goalId}_conversions
|
||||
`);
|
||||
} else if (goal.goalType === "event") {
|
||||
const eventName = goal.config.eventName;
|
||||
const eventPropertyKey = goal.config.eventPropertyKey;
|
||||
const eventPropertyValue = goal.config.eventPropertyValue;
|
||||
|
||||
if (!eventName) continue;
|
||||
|
||||
let eventClause = `type = 'custom_event' AND event_name = ${SqlString.escape(
|
||||
eventName
|
||||
)}`;
|
||||
|
||||
// Add property matching if needed
|
||||
if (eventPropertyKey && eventPropertyValue !== undefined) {
|
||||
// Access the sub-column directly for native JSON type
|
||||
const propValueAccessor = `props.${SqlString.escapeId(
|
||||
eventPropertyKey
|
||||
)}`;
|
||||
|
||||
// Comparison needs to handle the Dynamic type returned
|
||||
// Let ClickHouse handle the comparison based on the provided value type
|
||||
if (typeof eventPropertyValue === "string") {
|
||||
eventClause += ` AND toString(${propValueAccessor}) = ${SqlString.escape(
|
||||
eventPropertyValue
|
||||
)}`;
|
||||
} else if (typeof eventPropertyValue === "number") {
|
||||
// Use toFloat64 or toInt* depending on expected number type
|
||||
eventClause += ` AND toFloat64OrNull(${propValueAccessor}) = ${SqlString.escape(
|
||||
eventPropertyValue
|
||||
)}`;
|
||||
} else if (typeof eventPropertyValue === "boolean") {
|
||||
// Booleans might be stored as 0/1 or true/false in JSON
|
||||
// Comparing toUInt8 seems robust
|
||||
eventClause += ` AND toUInt8OrNull(${propValueAccessor}) = ${
|
||||
eventPropertyValue ? 1 : 0
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
conditionalClauses.push(`
|
||||
COUNT(DISTINCT IF(
|
||||
${eventClause},
|
||||
session_id,
|
||||
NULL
|
||||
)) AS goal_${goal.goalId}_conversions
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
if (conditionalClauses.length === 0) {
|
||||
// If no valid goals to calculate, return the goals without conversion data
|
||||
const goalsWithZeroConversions = siteGoals.map((goal) => ({
|
||||
...goal,
|
||||
total_conversions: 0,
|
||||
total_sessions: totalSessions,
|
||||
conversion_rate: 0,
|
||||
}));
|
||||
|
||||
return reply.send({
|
||||
data: goalsWithZeroConversions,
|
||||
meta: {
|
||||
total: totalGoals,
|
||||
page: pageNumber,
|
||||
pageSize: pageSizeNumber,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Execute the comprehensive query
|
||||
const conversionQuery = `
|
||||
SELECT
|
||||
${conditionalClauses.join(", ")}
|
||||
FROM events
|
||||
WHERE site_id = ${SqlString.escape(Number(site))}
|
||||
${timeStatement}
|
||||
${filterStatement}
|
||||
`;
|
||||
|
||||
const conversionResult = await clickhouse.query({
|
||||
query: conversionQuery,
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
const conversionData = await processResults<Record<string, number>>(
|
||||
conversionResult
|
||||
);
|
||||
|
||||
// If we didn't get any results, use zeros
|
||||
const conversions = conversionData[0] || {};
|
||||
|
||||
// Combine goals data with conversion metrics
|
||||
const goalsWithConversions: GoalWithConversions[] = siteGoals.map(
|
||||
(goal) => {
|
||||
const totalConversions =
|
||||
conversions[`goal_${goal.goalId}_conversions`] || 0;
|
||||
const conversionRate =
|
||||
totalSessions > 0 ? totalConversions / totalSessions : 0;
|
||||
|
||||
return {
|
||||
...goal,
|
||||
total_conversions: totalConversions,
|
||||
total_sessions: totalSessions,
|
||||
conversion_rate: conversionRate,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
data: goalsWithConversions,
|
||||
meta: {
|
||||
total: totalGoals,
|
||||
page: pageNumber,
|
||||
pageSize: pageSizeNumber,
|
||||
totalPages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching goals:", error);
|
||||
return reply.status(500).send({ error: "Failed to fetch goals data" });
|
||||
}
|
||||
}
|
|
@ -123,7 +123,7 @@ SELECT
|
|||
referrer,
|
||||
type,
|
||||
event_name,
|
||||
properties
|
||||
props
|
||||
FROM events
|
||||
WHERE
|
||||
site_id = {siteId:Int32}
|
||||
|
|
151
server/src/api/analytics/updateGoal.ts
Normal file
151
server/src/api/analytics/updateGoal.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../../db/postgres/postgres.js";
|
||||
import { goals } from "../../db/postgres/schema.js";
|
||||
import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Define validation schema for path pattern
|
||||
const pathPatternSchema = z.string().min(1, "Path pattern cannot be empty");
|
||||
|
||||
// Define validation schema for event config
|
||||
const eventConfigSchema = z
|
||||
.object({
|
||||
eventName: z.string().min(1, "Event name cannot be empty"),
|
||||
eventPropertyKey: z.string().optional(),
|
||||
eventPropertyValue: z
|
||||
.union([z.string(), z.number(), z.boolean()])
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// If one property matching field is provided, both must be provided
|
||||
if (data.eventPropertyKey && data.eventPropertyValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (data.eventPropertyValue !== undefined && !data.eventPropertyKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Both eventPropertyKey and eventPropertyValue must be provided together or omitted together",
|
||||
}
|
||||
);
|
||||
|
||||
// Define validation schema for the goal request
|
||||
const updateGoalSchema = z
|
||||
.object({
|
||||
goalId: z.number().int().positive("Goal ID must be a positive integer"),
|
||||
siteId: z.number().int().positive("Site ID must be a positive integer"),
|
||||
name: z.string().optional(),
|
||||
goalType: z.enum(["path", "event"]),
|
||||
config: z.object({
|
||||
pathPattern: z.string().optional(),
|
||||
eventName: z.string().optional(),
|
||||
eventPropertyKey: z.string().optional(),
|
||||
eventPropertyValue: z
|
||||
.union([z.string(), z.number(), z.boolean()])
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.goalType === "path") {
|
||||
return !!data.config.pathPattern;
|
||||
} else if (data.goalType === "event") {
|
||||
return !!data.config.eventName;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message: "Configuration must match goal type",
|
||||
path: ["config"],
|
||||
}
|
||||
);
|
||||
|
||||
type UpdateGoalRequest = z.infer<typeof updateGoalSchema>;
|
||||
|
||||
export async function updateGoal(
|
||||
request: FastifyRequest<{
|
||||
Body: UpdateGoalRequest;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
// Validate the request body
|
||||
const validatedData = updateGoalSchema.parse(request.body);
|
||||
const { goalId, siteId, name, goalType, config } = validatedData;
|
||||
|
||||
// Check if the goal exists
|
||||
const existingGoal = await db.query.goals.findFirst({
|
||||
where: eq(goals.goalId, goalId),
|
||||
});
|
||||
|
||||
if (!existingGoal) {
|
||||
return reply.status(404).send({ error: "Goal not found" });
|
||||
}
|
||||
|
||||
// Check if the goal belongs to the specified site
|
||||
if (existingGoal.siteId !== siteId) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Goal does not belong to the specified site" });
|
||||
}
|
||||
|
||||
// Check user access to site
|
||||
const userHasAccessToSite = await getUserHasAccessToSite(
|
||||
request,
|
||||
siteId.toString()
|
||||
);
|
||||
if (!userHasAccessToSite) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
// Additional validation based on goal type
|
||||
if (goalType === "path") {
|
||||
// Validate path pattern
|
||||
pathPatternSchema.parse(config.pathPattern);
|
||||
} else if (goalType === "event") {
|
||||
// Validate event configuration
|
||||
eventConfigSchema.parse({
|
||||
eventName: config.eventName,
|
||||
eventPropertyKey: config.eventPropertyKey,
|
||||
eventPropertyValue: config.eventPropertyValue,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the goal
|
||||
const result = await db
|
||||
.update(goals)
|
||||
.set({
|
||||
name: name || null, // Use null if name is not provided
|
||||
goalType,
|
||||
config,
|
||||
})
|
||||
.where(eq(goals.goalId, goalId))
|
||||
.returning({ goalId: goals.goalId });
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
return reply.status(500).send({ error: "Failed to update goal" });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
goalId: result[0].goalId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating goal:", error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.status(400).send({
|
||||
error: "Validation error",
|
||||
details: error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({ error: "Failed to update goal" });
|
||||
}
|
||||
}
|
|
@ -41,7 +41,7 @@ export const initializeClickhouse = async () => {
|
|||
device_type LowCardinality(String),
|
||||
type LowCardinality(String) DEFAULT 'pageview',
|
||||
event_name String,
|
||||
properties String
|
||||
props JSON
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(timestamp)
|
||||
|
@ -82,6 +82,7 @@ export const initializeClickhouse = async () => {
|
|||
ORDER BY (site_id, session_id);
|
||||
`,
|
||||
});
|
||||
|
||||
await clickhouse.exec({
|
||||
query: `
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS sessions_mv
|
||||
|
|
|
@ -233,3 +233,31 @@ export const session = pgTable(
|
|||
unique("session_token_unique").on(table.token),
|
||||
]
|
||||
);
|
||||
|
||||
// Goals table for tracking conversion goals
|
||||
export const goals = pgTable(
|
||||
"goals",
|
||||
{
|
||||
goalId: serial("goal_id").primaryKey().notNull(),
|
||||
siteId: integer("site_id").notNull(),
|
||||
name: text("name"), // Optional, user-defined name for the goal
|
||||
goalType: text("goal_type").notNull(), // 'path' or 'event'
|
||||
// Configuration specific to the goal type
|
||||
config: jsonb("config").notNull().$type<{
|
||||
// For 'path' type
|
||||
pathPattern?: string; // e.g., "/pricing", "/product/*/view", "/docs/**"
|
||||
// For 'event' type
|
||||
eventName?: string; // e.g., "signup_completed", "file_downloaded"
|
||||
eventPropertyKey?: string; // Optional property key to match
|
||||
eventPropertyValue?: string | number | boolean; // Optional property value to match (exact match)
|
||||
}>(),
|
||||
createdAt: timestamp("created_at", { mode: "string" }).defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.siteId],
|
||||
foreignColumns: [sites.siteId],
|
||||
name: "goals_site_id_sites_site_id_fk",
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
|
|
@ -6,10 +6,14 @@ import { dirname, join } from "path";
|
|||
import { Headers, HeadersInit } from "undici";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createFunnel } from "./api/analytics/createFunnel.js";
|
||||
import { createGoal } from "./api/analytics/createGoal.js";
|
||||
import { deleteGoal } from "./api/analytics/deleteGoal.js";
|
||||
import { deleteReport } from "./api/analytics/deleteReport.js";
|
||||
import { getEvents } from "./api/analytics/getEvents.js";
|
||||
import { getFunnel } from "./api/analytics/getFunnel.js";
|
||||
import { getFunnels } from "./api/analytics/getFunnels.js";
|
||||
import { getGoal } from "./api/analytics/getGoal.js";
|
||||
import { getGoals } from "./api/analytics/getGoals.js";
|
||||
import { getJourneys } from "./api/analytics/getJourneys.js";
|
||||
import { getLiveSessionLocations } from "./api/analytics/getLiveSessionLocations.js";
|
||||
import { getLiveUsercount } from "./api/analytics/getLiveUsercount.js";
|
||||
|
@ -23,6 +27,7 @@ import { getUserInfo } from "./api/analytics/getUserInfo.js";
|
|||
import { getUserSessionCount } from "./api/analytics/getUserSessionCount.js";
|
||||
import { getUserSessions } from "./api/analytics/getUserSessions.js";
|
||||
import { getUsers } from "./api/analytics/getUsers.js";
|
||||
import { updateGoal } from "./api/analytics/updateGoal.js";
|
||||
import { addSite } from "./api/sites/addSite.js";
|
||||
import { changeSiteDomain } from "./api/sites/changeSiteDomain.js";
|
||||
import { changeSitePublic } from "./api/sites/changeSitePublic.js";
|
||||
|
@ -136,6 +141,8 @@ const ANALYTICS_ROUTES = [
|
|||
"/funnels/",
|
||||
"/funnel/",
|
||||
"/journeys/",
|
||||
"/goals/",
|
||||
"/goal/",
|
||||
|
||||
"/get-site",
|
||||
];
|
||||
|
@ -205,6 +212,11 @@ server.get("/journeys/:site", getJourneys);
|
|||
server.post("/funnel/:site", getFunnel);
|
||||
server.post("/funnel/create/:site", createFunnel);
|
||||
server.delete("/report/:reportId", deleteReport);
|
||||
server.get("/goals/:site", getGoals);
|
||||
server.get("/goal/:goalId/:site", getGoal);
|
||||
server.post("/goal/create", createGoal);
|
||||
server.delete("/goal/:goalId", deleteGoal);
|
||||
server.put("/goal/update", updateGoal);
|
||||
|
||||
// Administrative
|
||||
server.post("/add-site", addSite);
|
||||
|
|
|
@ -18,6 +18,14 @@ type TotalPayload = TrackingPayload & {
|
|||
properties?: string;
|
||||
};
|
||||
|
||||
const getParsedProperties = (properties: string | undefined) => {
|
||||
try {
|
||||
return properties ? JSON.parse(properties) : undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
class PageviewQueue {
|
||||
private queue: TotalPayload[] = [];
|
||||
private batchSize = 5000;
|
||||
|
@ -104,7 +112,7 @@ class PageviewQueue {
|
|||
lon: longitude || 0,
|
||||
type: pv.type || "pageview",
|
||||
event_name: pv.event_name || "",
|
||||
properties: pv.properties,
|
||||
props: getParsedProperties(pv.properties),
|
||||
utm_source: utmParams["utm_source"] || "",
|
||||
utm_medium: utmParams["utm_medium"] || "",
|
||||
utm_campaign: utmParams["utm_campaign"] || "",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue