diff --git a/clickhouse_config/enable_json.xml b/clickhouse_config/enable_json.xml new file mode 100644 index 0000000..ac14408 --- /dev/null +++ b/clickhouse_config/enable_json.xml @@ -0,0 +1,5 @@ + + + 1 + + \ No newline at end of file diff --git a/clickhouse_config/network.xml b/clickhouse_config/network.xml index 5c988e3..e4f4b22 100644 --- a/clickhouse_config/network.xml +++ b/clickhouse_config/network.xml @@ -1,4 +1,3 @@ - :: 0.0.0.0 - \ No newline at end of file + \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 6f4dc6d..f95c67d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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" } diff --git a/client/package.json b/client/package.json index 754a53f..df18185 100644 --- a/client/package.json +++ b/client/package.json @@ -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": { diff --git a/client/src/api/analytics/useCreateGoal.ts b/client/src/api/analytics/useCreateGoal.ts new file mode 100644 index 0000000..096bf03 --- /dev/null +++ b/client/src/api/analytics/useCreateGoal.ts @@ -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({ + mutationFn: async (goalData) => { + try { + return await authedFetchWithError( + `${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()], + }); + }, + }); +} diff --git a/client/src/api/analytics/useDeleteGoal.ts b/client/src/api/analytics/useDeleteGoal.ts new file mode 100644 index 0000000..31f8f1e --- /dev/null +++ b/client/src/api/analytics/useDeleteGoal.ts @@ -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], + }); + }, + }); +} diff --git a/client/src/api/analytics/useGetGoal.ts b/client/src/api/analytics/useGetGoal.ts new file mode 100644 index 0000000..95f5f71 --- /dev/null +++ b/client/src/api/analytics/useGetGoal.ts @@ -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, + }); +} diff --git a/client/src/api/analytics/useGetGoals.ts b/client/src/api/analytics/useGetGoals.ts new file mode 100644 index 0000000..cb7df05 --- /dev/null +++ b/client/src/api/analytics/useGetGoals.ts @@ -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, + }); +} diff --git a/client/src/api/analytics/useUpdateGoal.ts b/client/src/api/analytics/useUpdateGoal.ts new file mode 100644 index 0000000..02d24ce --- /dev/null +++ b/client/src/api/analytics/useUpdateGoal.ts @@ -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({ + mutationFn: async (goalData) => { + try { + return await authedFetchWithError( + `${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], + }); + }, + }); +} diff --git a/client/src/app/[site]/components/Sidebar/Sidebar.tsx b/client/src/app/[site]/components/Sidebar/Sidebar.tsx index d8adab2..c8c7e62 100644 --- a/client/src/app/[site]/components/Sidebar/Sidebar.tsx +++ b/client/src/app/[site]/components/Sidebar/Sidebar.tsx @@ -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={} /> + } + /> + + + Add Goal + + } + /> + + ); +} diff --git a/client/src/app/[site]/goals/components/GoalCard.tsx b/client/src/app/[site]/goals/components/GoalCard.tsx new file mode 100644 index 0000000..5e724a4 --- /dev/null +++ b/client/src/app/[site]/goals/components/GoalCard.tsx @@ -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 ( + <> + + +
+ + {goal.name || `Goal #${goal.goalId}`} + + +
+ + + + } + /> + +
+
+
+ +
+
+ Type: + {goal.goalType === "path" ? ( +
+ + Path Goal +
+ ) : ( +
+ + Event Goal +
+ )} +
+
+ Pattern: +
+ {goal.goalType === "path" ? ( + + {goal.config.pathPattern} + + ) : ( + + {goal.config.eventName} + + )} +
+ {goal.goalType === "event" && goal.config.eventPropertyKey && ( +
+ Property:{" "} + + {goal.config.eventPropertyKey} ={" "} + {String(goal.config.eventPropertyValue)} + +
+ )} +
+
+
+ +
+
{goal.total_conversions}
+
Conversions
+
+
+
+ {(goal.conversion_rate * 100).toFixed(2)}% +
+
Conversion Rate
+
+
+
+ + {/* Delete Confirmation Dialog */} + + + + + Are you sure you want to delete this goal? + + + This action cannot be undone. This will permanently delete the + goal and remove it from all reports. + + + + Cancel + + {deleteGoalMutation.isPending ? "Deleting..." : "Delete"} + + + + + + ); +} diff --git a/client/src/app/[site]/goals/components/GoalFormModal.tsx b/client/src/app/[site]/goals/components/GoalFormModal.tsx new file mode 100644 index 0000000..5953d38 --- /dev/null +++ b/client/src/app/[site]/goals/components/GoalFormModal.tsx @@ -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; + +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({ + 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 ( + { + setIsOpen(open); + if (!open) { + form.reset(); + } + }} + > + {trigger} + + + {isEditMode ? "Edit Goal" : "Create Goal"} + + {isEditMode + ? "Update the goal details below." + : "Set up a new conversion goal to track specific user actions."} + + + +
+ + ( + + Goal Name (optional) + + + + + + )} + /> + + ( + + Goal Type + + + + )} + /> + + {goalType === "path" && ( + ( + + Path Pattern + + + + +
+ Use * to match a single path segment. Use ** to match + across segments. +
+
+ )} + /> + )} + + {goalType === "event" && ( + <> + ( + + Event Name + + + + + + )} + /> + +
+
+ + +
+ + {useProperties && ( +
+ ( + + Property Key + + + + + + )} + /> + + ( + + Property Value + + + + + + )} + /> +
+ )} +
+ + )} + +
+ + +
+ + +
+
+ ); +} diff --git a/client/src/app/[site]/goals/components/GoalsList.tsx b/client/src/app/[site]/goals/components/GoalsList.tsx new file mode 100644 index 0000000..6cdea6f --- /dev/null +++ b/client/src/app/[site]/goals/components/GoalsList.tsx @@ -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 ( +
+
+ {goals.map((goal) => ( + + ))} +
+ + {totalPages > 1 && ( +
+
+ Showing {goals.length} of {total} goals +
+
+ +
+ Page {page} of {totalPages} +
+ +
+
+ )} +
+ ); +} diff --git a/client/src/app/[site]/goals/page.tsx b/client/src/app/[site]/goals/page.tsx new file mode 100644 index 0000000..ad764d7 --- /dev/null +++ b/client/src/app/[site]/goals/page.tsx @@ -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 ( +
+ +
+
+ +
+ + {isLoading ? ( +
+
Loading goals data...
+
+ ) : !goalsData || goalsData.data.length === 0 ? ( +
+

No goals configured yet

+

+ Create your first conversion goal to start tracking important user + actions. +

+
+ ) : ( + + )} +
+ ); +} diff --git a/client/src/components/ui/form.tsx b/client/src/components/ui/form.tsx new file mode 100644 index 0000000..0fbc12c --- /dev/null +++ b/client/src/components/ui/form.tsx @@ -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 = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +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 ") + } + + 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( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +