Rename plugins to automations and fix scheduler/payment bugs
Major changes: - Rename "plugins" to "automations" throughout codebase - Move automation system to dedicated app (scheduling/automations/) - Add new automation marketplace, creation, and management pages Bug fixes: - Fix payment endpoint 500 errors (use has_feature() instead of attribute) - Fix scheduler showing "0 AM" instead of "12 AM" - Fix scheduler business hours double-inversion display issue - Fix scheduler scroll-to-current-time when switching views - Fix week view centering on wrong day (use Sunday-based indexing) - Fix capacity widget overflow with many resources - Fix Recharts minWidth/minHeight console warnings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
543
frontend/package-lock.json
generated
543
frontend/package-lock.json
generated
@@ -57,6 +57,7 @@
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.15"
|
||||
@@ -4074,6 +4075,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -5970,6 +5984,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
@@ -6285,6 +6309,525 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz",
|
||||
"integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz",
|
||||
"integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz",
|
||||
"integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz",
|
||||
"integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz",
|
||||
"integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz",
|
||||
"integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz",
|
||||
"integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz",
|
||||
"integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz",
|
||||
"integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz",
|
||||
"integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz",
|
||||
"integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz",
|
||||
"integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
|
||||
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.1",
|
||||
"@esbuild/android-arm": "0.27.1",
|
||||
"@esbuild/android-arm64": "0.27.1",
|
||||
"@esbuild/android-x64": "0.27.1",
|
||||
"@esbuild/darwin-arm64": "0.27.1",
|
||||
"@esbuild/darwin-x64": "0.27.1",
|
||||
"@esbuild/freebsd-arm64": "0.27.1",
|
||||
"@esbuild/freebsd-x64": "0.27.1",
|
||||
"@esbuild/linux-arm": "0.27.1",
|
||||
"@esbuild/linux-arm64": "0.27.1",
|
||||
"@esbuild/linux-ia32": "0.27.1",
|
||||
"@esbuild/linux-loong64": "0.27.1",
|
||||
"@esbuild/linux-mips64el": "0.27.1",
|
||||
"@esbuild/linux-ppc64": "0.27.1",
|
||||
"@esbuild/linux-riscv64": "0.27.1",
|
||||
"@esbuild/linux-s390x": "0.27.1",
|
||||
"@esbuild/linux-x64": "0.27.1",
|
||||
"@esbuild/netbsd-arm64": "0.27.1",
|
||||
"@esbuild/netbsd-x64": "0.27.1",
|
||||
"@esbuild/openbsd-arm64": "0.27.1",
|
||||
"@esbuild/openbsd-x64": "0.27.1",
|
||||
"@esbuild/openharmony-arm64": "0.27.1",
|
||||
"@esbuild/sunos-x64": "0.27.1",
|
||||
"@esbuild/win32-arm64": "0.27.1",
|
||||
"@esbuild/win32-ia32": "0.27.1",
|
||||
"@esbuild/win32-x64": "0.27.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"jsdom": "^27.2.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.15"
|
||||
@@ -68,6 +69,12 @@
|
||||
"test:watch": "vitest --watch",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"crawl": "tsx scripts/crawl-site.ts",
|
||||
"crawl:public": "tsx scripts/crawl-site.ts --public",
|
||||
"crawl:platform": "tsx scripts/crawl-site.ts --platform",
|
||||
"crawl:all": "tsx scripts/crawl-site.ts --all",
|
||||
"crawl:verbose": "tsx scripts/crawl-site.ts --all --verbose",
|
||||
"test:crawl": "playwright test site-crawler.spec.ts"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
293
frontend/scripts/crawl-site.ts
Normal file
293
frontend/scripts/crawl-site.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Site Crawler Script
|
||||
*
|
||||
* Crawls the SmoothSchedule site looking for:
|
||||
* - Broken links
|
||||
* - Console errors
|
||||
* - Network failures
|
||||
* - Page errors
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node scripts/crawl-site.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --public Crawl public marketing site only
|
||||
* --platform Crawl platform dashboard (requires login)
|
||||
* --tenant Crawl tenant dashboard (requires login)
|
||||
* --all Crawl all areas (default)
|
||||
* --max-pages=N Maximum pages to crawl per area (default: 50)
|
||||
* --verbose Show detailed logging
|
||||
* --screenshots Save screenshots on errors
|
||||
*
|
||||
* Examples:
|
||||
* npx ts-node scripts/crawl-site.ts --public
|
||||
* npx ts-node scripts/crawl-site.ts --all --max-pages=100
|
||||
* npx ts-node scripts/crawl-site.ts --platform --verbose
|
||||
*/
|
||||
|
||||
import { chromium, Browser, BrowserContext, Page } from 'playwright';
|
||||
import {
|
||||
SiteCrawler,
|
||||
CrawlerOptions,
|
||||
CrawlReport,
|
||||
formatReport,
|
||||
loginAsUser,
|
||||
TEST_USERS,
|
||||
UserCredentials,
|
||||
} from '../tests/e2e/utils/crawler';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface CrawlArea {
|
||||
name: string;
|
||||
startUrl: string;
|
||||
requiresAuth: boolean;
|
||||
user?: UserCredentials;
|
||||
}
|
||||
|
||||
const CRAWL_AREAS: Record<string, CrawlArea> = {
|
||||
public: {
|
||||
name: 'Public Site',
|
||||
startUrl: 'http://lvh.me:5173',
|
||||
requiresAuth: false,
|
||||
},
|
||||
platform: {
|
||||
name: 'Platform Dashboard',
|
||||
startUrl: 'http://platform.lvh.me:5173/platform/login',
|
||||
requiresAuth: true,
|
||||
user: TEST_USERS.platformSuperuser,
|
||||
},
|
||||
tenant: {
|
||||
name: 'Tenant Dashboard (Owner)',
|
||||
startUrl: 'http://demo.lvh.me:5173/login',
|
||||
requiresAuth: true,
|
||||
user: TEST_USERS.businessOwner,
|
||||
},
|
||||
tenantStaff: {
|
||||
name: 'Tenant Dashboard (Staff)',
|
||||
startUrl: 'http://demo.lvh.me:5173/login',
|
||||
requiresAuth: true,
|
||||
user: TEST_USERS.businessStaff,
|
||||
},
|
||||
customer: {
|
||||
name: 'Customer Booking Portal (Public)',
|
||||
startUrl: 'http://demo.lvh.me:5173/book',
|
||||
requiresAuth: false, // Public booking page
|
||||
},
|
||||
customerPortal: {
|
||||
name: 'Customer Portal (Logged In)',
|
||||
startUrl: 'http://demo.lvh.me:5173/login',
|
||||
requiresAuth: true,
|
||||
user: TEST_USERS.customer,
|
||||
},
|
||||
};
|
||||
|
||||
function parseArgs(): {
|
||||
areas: string[];
|
||||
maxPages: number;
|
||||
verbose: boolean;
|
||||
screenshots: boolean;
|
||||
} {
|
||||
const args = process.argv.slice(2);
|
||||
const result = {
|
||||
areas: [] as string[],
|
||||
maxPages: 0, // 0 = unlimited
|
||||
verbose: false,
|
||||
screenshots: false,
|
||||
};
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--public') result.areas.push('public');
|
||||
else if (arg === '--platform') result.areas.push('platform');
|
||||
else if (arg === '--tenant') result.areas.push('tenant');
|
||||
else if (arg === '--tenant-staff') result.areas.push('tenantStaff');
|
||||
else if (arg === '--customer') result.areas.push('customer');
|
||||
else if (arg === '--customer-portal') result.areas.push('customerPortal');
|
||||
else if (arg === '--all') result.areas = Object.keys(CRAWL_AREAS);
|
||||
else if (arg.startsWith('--max-pages=')) result.maxPages = parseInt(arg.split('=')[1], 10);
|
||||
else if (arg === '--verbose') result.verbose = true;
|
||||
else if (arg === '--screenshots') result.screenshots = true;
|
||||
else if (arg === '--help' || arg === '-h') {
|
||||
console.log(`
|
||||
Site Crawler - Find broken links and errors
|
||||
|
||||
Usage: npm run crawl -- [options]
|
||||
|
||||
Options:
|
||||
--public Crawl public marketing site only
|
||||
--platform Crawl platform dashboard (requires login)
|
||||
--tenant Crawl tenant dashboard as Business Owner
|
||||
--tenant-staff Crawl tenant dashboard as Staff Member
|
||||
--customer Crawl customer booking portal (public)
|
||||
--all Crawl all areas
|
||||
--max-pages=N Maximum pages to crawl per area (default: 50)
|
||||
--verbose Show detailed logging
|
||||
--screenshots Save screenshots on errors
|
||||
|
||||
Examples:
|
||||
npm run crawl -- --public
|
||||
npm run crawl -- --tenant --max-pages=50
|
||||
npm run crawl -- --all --verbose
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to public only if no areas specified
|
||||
if (result.areas.length === 0) {
|
||||
result.areas = ['public'];
|
||||
}
|
||||
|
||||
// Filter out areas that don't exist in CRAWL_AREAS
|
||||
result.areas = result.areas.filter(area => CRAWL_AREAS[area]);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function crawlArea(
|
||||
browser: Browser,
|
||||
area: CrawlArea,
|
||||
options: CrawlerOptions
|
||||
): Promise<CrawlReport> {
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log(` Crawling: ${area.name}`);
|
||||
console.log(` URL: ${area.startUrl}`);
|
||||
console.log(`${'═'.repeat(60)}`);
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// Login if required
|
||||
let crawlStartUrl = area.startUrl;
|
||||
if (area.requiresAuth && area.user) {
|
||||
const loggedIn = await loginAsUser(page, area.user);
|
||||
if (!loggedIn) {
|
||||
console.error(`Failed to login for ${area.name}. Skipping.`);
|
||||
return {
|
||||
startTime: new Date(),
|
||||
endTime: new Date(),
|
||||
totalPages: 0,
|
||||
totalErrors: 1,
|
||||
results: [],
|
||||
summary: {
|
||||
consoleErrors: 0,
|
||||
networkErrors: 0,
|
||||
brokenLinks: 0,
|
||||
pageErrors: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
// After login, start from the current URL (typically dashboard)
|
||||
crawlStartUrl = page.url();
|
||||
}
|
||||
|
||||
// Create crawler and run
|
||||
const crawler = new SiteCrawler(page, context, options);
|
||||
const report = await crawler.crawl(crawlStartUrl);
|
||||
|
||||
return report;
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function saveReport(reports: Map<string, CrawlReport>): string {
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.join(process.cwd(), 'test-results');
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = path.join(outputDir, `crawl-report-${timestamp}.json`);
|
||||
|
||||
const reportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
areas: Object.fromEntries(reports),
|
||||
};
|
||||
|
||||
fs.writeFileSync(filename, JSON.stringify(reportData, null, 2));
|
||||
return filename;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { areas, maxPages, verbose, screenshots } = parseArgs();
|
||||
|
||||
console.log('\n🕷️ SmoothSchedule Site Crawler');
|
||||
console.log('─'.repeat(40));
|
||||
console.log(`Areas to crawl: ${areas.join(', ')}`);
|
||||
console.log(`Max pages per area: ${maxPages}`);
|
||||
console.log(`Verbose: ${verbose}`);
|
||||
console.log(`Screenshots: ${screenshots}`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
});
|
||||
|
||||
const options: CrawlerOptions = {
|
||||
maxPages,
|
||||
verbose,
|
||||
screenshotOnError: screenshots,
|
||||
screenshotDir: 'test-results/crawler-screenshots',
|
||||
timeout: 30000,
|
||||
waitForNetworkIdle: true,
|
||||
};
|
||||
|
||||
// Ensure screenshot directory exists if enabled
|
||||
if (screenshots && !fs.existsSync(options.screenshotDir!)) {
|
||||
fs.mkdirSync(options.screenshotDir!, { recursive: true });
|
||||
}
|
||||
|
||||
const reports = new Map<string, CrawlReport>();
|
||||
let totalErrors = 0;
|
||||
|
||||
try {
|
||||
for (const areaKey of areas) {
|
||||
const area = CRAWL_AREAS[areaKey];
|
||||
if (!area) continue;
|
||||
|
||||
const report = await crawlArea(browser, area, options);
|
||||
reports.set(areaKey, report);
|
||||
totalErrors += report.totalErrors;
|
||||
|
||||
// Print report for this area
|
||||
console.log(formatReport(report));
|
||||
}
|
||||
|
||||
// Save combined report
|
||||
const reportFile = saveReport(reports);
|
||||
console.log(`\n📄 Full report saved to: ${reportFile}`);
|
||||
|
||||
// Final summary
|
||||
console.log('\n' + '═'.repeat(60));
|
||||
console.log(' FINAL SUMMARY');
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
let totalPages = 0;
|
||||
for (const [areaKey, report] of reports) {
|
||||
const area = CRAWL_AREAS[areaKey];
|
||||
const icon = report.totalErrors === 0 ? '✅' : '❌';
|
||||
console.log(`${icon} ${area.name}: ${report.totalPages} pages, ${report.totalErrors} errors`);
|
||||
totalPages += report.totalPages;
|
||||
}
|
||||
|
||||
console.log('─'.repeat(60));
|
||||
console.log(` Total: ${totalPages} pages crawled, ${totalErrors} errors found`);
|
||||
console.log('═'.repeat(60) + '\n');
|
||||
|
||||
// Exit with error code if errors found
|
||||
process.exit(totalErrors > 0 ? 1 : 0);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Crawler failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets p
|
||||
const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platform Guide page
|
||||
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
|
||||
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
|
||||
const HelpPluginDocs = React.lazy(() => import('./pages/help/HelpPluginDocs')); // Import Plugin documentation page
|
||||
const HelpAutomationDocs = React.lazy(() => import('./pages/help/HelpAutomationDocs')); // Import Automation documentation page
|
||||
const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page
|
||||
|
||||
// Import new help pages
|
||||
@@ -90,7 +90,7 @@ const HelpTimeBlocks = React.lazy(() => import('./pages/HelpTimeBlocks'));
|
||||
const HelpMessages = React.lazy(() => import('./pages/help/HelpMessages'));
|
||||
const HelpPayments = React.lazy(() => import('./pages/help/HelpPayments'));
|
||||
const HelpContracts = React.lazy(() => import('./pages/help/HelpContracts'));
|
||||
const HelpPlugins = React.lazy(() => import('./pages/help/HelpPlugins'));
|
||||
const HelpAutomations = React.lazy(() => import('./pages/help/HelpAutomations'));
|
||||
const HelpSettingsGeneral = React.lazy(() => import('./pages/help/HelpSettingsGeneral'));
|
||||
const HelpSettingsResourceTypes = React.lazy(() => import('./pages/help/HelpSettingsResourceTypes'));
|
||||
const HelpSettingsBooking = React.lazy(() => import('./pages/help/HelpSettingsBooking'));
|
||||
@@ -104,10 +104,10 @@ const HelpSettingsQuota = React.lazy(() => import('./pages/help/HelpSettingsQuot
|
||||
const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive'));
|
||||
const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp'));
|
||||
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
|
||||
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
|
||||
const AutomationMarketplace = React.lazy(() => import('./pages/AutomationMarketplace')); // Import Automation Marketplace page
|
||||
const MyAutomations = React.lazy(() => import('./pages/MyAutomations')); // Import My Automations page
|
||||
const CreateAutomation = React.lazy(() => import('./pages/CreateAutomation')); // Import Create Automation page
|
||||
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled automation executions
|
||||
const SystemEmailTemplates = React.lazy(() => import('./pages/settings/SystemEmailTemplates')); // System email templates (Puck-based)
|
||||
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
|
||||
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
|
||||
@@ -518,7 +518,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||
<Route path="/help/automations" element={<HelpAutomationDocs />} />
|
||||
<Route path="/help/email" element={<HelpEmailSettings />} />
|
||||
{user.role === 'superuser' && (
|
||||
<>
|
||||
@@ -738,7 +738,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/dashboard/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/dashboard/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/dashboard/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/dashboard/help/plugins/docs" element={<HelpPluginDocs />} />
|
||||
<Route path="/dashboard/help/automations/docs" element={<HelpAutomationDocs />} />
|
||||
<Route path="/dashboard/help/email" element={<HelpEmailSettings />} />
|
||||
{/* New help pages */}
|
||||
<Route path="/dashboard/help/dashboard" element={<HelpDashboard />} />
|
||||
@@ -752,7 +752,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/dashboard/help/messages" element={<HelpMessages />} />
|
||||
<Route path="/dashboard/help/payments" element={<HelpPayments />} />
|
||||
<Route path="/dashboard/help/contracts" element={<HelpContracts />} />
|
||||
<Route path="/dashboard/help/plugins" element={<HelpPlugins />} />
|
||||
<Route path="/dashboard/help/automations" element={<HelpAutomations />} />
|
||||
<Route path="/dashboard/help/settings/general" element={<HelpSettingsGeneral />} />
|
||||
<Route path="/dashboard/help/settings/resource-types" element={<HelpSettingsResourceTypes />} />
|
||||
<Route path="/dashboard/help/settings/booking" element={<HelpSettingsBooking />} />
|
||||
@@ -764,30 +764,30 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/dashboard/help/settings/billing" element={<HelpSettingsBilling />} />
|
||||
<Route path="/dashboard/help/settings/quota" element={<HelpSettingsQuota />} />
|
||||
<Route
|
||||
path="/dashboard/plugins/marketplace"
|
||||
path="/dashboard/automations/marketplace"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<PluginMarketplace />
|
||||
<AutomationMarketplace />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/plugins/my-plugins"
|
||||
path="/dashboard/automations/my-automations"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<MyPlugins />
|
||||
<MyAutomations />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/plugins/create"
|
||||
path="/dashboard/automations/create"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<CreatePlugin />
|
||||
<CreateAutomation />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../constants/schedulePresets';
|
||||
import { ErrorMessage } from './ui';
|
||||
|
||||
interface PluginInstallation {
|
||||
interface AutomationInstallation {
|
||||
id: string;
|
||||
template: number;
|
||||
template_name: string;
|
||||
@@ -42,7 +42,7 @@ type TaskType = 'scheduled' | 'event';
|
||||
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSuccess }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState(1);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
|
||||
const [selectedAutomation, setSelectedAutomation] = useState<AutomationInstallation | null>(null);
|
||||
const [taskName, setTaskName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
@@ -68,20 +68,20 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Fetch available plugins
|
||||
const { data: plugins = [], isLoading: pluginsLoading } = useQuery<PluginInstallation[]>({
|
||||
queryKey: ['plugin-installations'],
|
||||
// Fetch available automations
|
||||
const { data: automations = [], isLoading: automationsLoading } = useQuery<AutomationInstallation[]>({
|
||||
queryKey: ['automation-installations'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('/plugin-installations/');
|
||||
// Filter out plugins that already have scheduled tasks
|
||||
return data.filter((p: PluginInstallation) => !p.scheduled_task);
|
||||
const { data } = await axios.get('/automation-installations/');
|
||||
// Filter out automations that already have scheduled tasks
|
||||
return data.filter((p: AutomationInstallation) => !p.scheduled_task);
|
||||
},
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setStep(1);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setTaskName('');
|
||||
setDescription('');
|
||||
setError('');
|
||||
@@ -97,9 +97,9 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePluginSelect = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
setTaskName(`${plugin.template_name} - Scheduled Task`);
|
||||
const handleAutomationSelect = (automation: AutomationInstallation) => {
|
||||
setSelectedAutomation(automation);
|
||||
setTaskName(`${automation.template_name} - Scheduled Task`);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
@@ -117,33 +117,33 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
const showOffset = !['on_complete', 'on_cancel'].includes(selectedTrigger);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedPlugin) return;
|
||||
if (!selectedAutomation) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (taskType === 'event') {
|
||||
// Create global event plugin (applies to all events)
|
||||
// Create global event automation (applies to all events)
|
||||
const payload = {
|
||||
plugin_installation: selectedPlugin.id,
|
||||
automation_installation: selectedAutomation.id,
|
||||
trigger: selectedTrigger,
|
||||
offset_minutes: selectedOffset,
|
||||
is_active: true,
|
||||
apply_to_existing: applyToExisting,
|
||||
};
|
||||
|
||||
await axios.post('/global-event-plugins/', payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
|
||||
toast.success(applyToExisting ? 'Plugin attached to all events' : 'Plugin will apply to future events');
|
||||
await axios.post('/global-event-automations/', payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
|
||||
toast.success(applyToExisting ? 'Automation attached to all events' : 'Automation will apply to future events');
|
||||
} else {
|
||||
// Create scheduled task
|
||||
let payload: any = {
|
||||
name: taskName,
|
||||
description,
|
||||
plugin_name: selectedPlugin.template_slug,
|
||||
automation_name: selectedAutomation.template_slug,
|
||||
status: 'ACTIVE',
|
||||
plugin_config: selectedPlugin.config_values || {},
|
||||
automation_config: selectedAutomation.config_values || {},
|
||||
};
|
||||
|
||||
if (scheduleMode === 'onetime') {
|
||||
@@ -202,7 +202,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 1 ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500'}`}>
|
||||
1
|
||||
</div>
|
||||
<span className="font-medium">Select Plugin</span>
|
||||
<span className="font-medium">Select Automation</span>
|
||||
</div>
|
||||
<div className="w-16 h-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className={`flex items-center gap-2 ${step >= 2 ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400'}`}>
|
||||
@@ -216,36 +216,36 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{/* Step 1: Select Plugin */}
|
||||
{/* Step 1: Select Automation */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Select a plugin to schedule for automatic execution.
|
||||
Select an automation to schedule for automatic execution.
|
||||
</p>
|
||||
|
||||
{pluginsLoading ? (
|
||||
{automationsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
) : automations.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<Zap className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">No available plugins</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-2">No available automations</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
Install plugins from the Marketplace first, or all your plugins are already scheduled.
|
||||
Install automations from the Marketplace first, or all your automations are already scheduled.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 max-h-96 overflow-y-auto">
|
||||
{plugins.map((plugin) => (
|
||||
{automations.map((automation) => (
|
||||
<button
|
||||
key={plugin.id}
|
||||
onClick={() => handlePluginSelect(plugin)}
|
||||
key={automation.id}
|
||||
onClick={() => handleAutomationSelect(automation)}
|
||||
className="text-left p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{plugin.logo_url ? (
|
||||
<img src={plugin.logo_url} alt="" className="w-10 h-10 rounded" />
|
||||
{automation.logo_url ? (
|
||||
<img src={automation.logo_url} alt="" className="w-10 h-10 rounded" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
@@ -253,10 +253,10 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{plugin.template_name}
|
||||
{automation.template_name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{plugin.template_description}
|
||||
{automation.template_description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,18 +268,18 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure Schedule */}
|
||||
{step === 2 && selectedPlugin && (
|
||||
{step === 2 && selectedAutomation && (
|
||||
<div className="space-y-6">
|
||||
{/* Selected Plugin */}
|
||||
{/* Selected Automation */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium">
|
||||
Selected Plugin
|
||||
Selected Automation
|
||||
</p>
|
||||
<p className="text-blue-900 dark:text-blue-100">
|
||||
{selectedPlugin.template_name}
|
||||
{selectedAutomation.template_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,7 +288,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
{/* Task Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
How should this plugin run?
|
||||
How should this automation run?
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
@@ -519,7 +519,7 @@ const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onSu
|
||||
{/* Info about timing updates */}
|
||||
<div className="p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
If an event is rescheduled, the plugin timing will automatically update.
|
||||
If an event is rescheduled, the automation timing will automatically update.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
plugin_display_name: string;
|
||||
automation_name: string;
|
||||
automation_display_name: string;
|
||||
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
|
||||
cron_expression?: string;
|
||||
interval_minutes?: number;
|
||||
@@ -17,7 +17,7 @@ interface ScheduledTask {
|
||||
last_run_at?: string;
|
||||
status: 'ACTIVE' | 'PAUSED' | 'DISABLED';
|
||||
last_run_status?: string;
|
||||
plugin_config: Record<string, any>;
|
||||
automation_config: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -198,16 +198,16 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Plugin Info (read-only) */}
|
||||
{/* Automation Info (read-only) */}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium">
|
||||
Plugin
|
||||
Automation
|
||||
</p>
|
||||
<p className="text-blue-900 dark:text-blue-100">
|
||||
{task.plugin_display_name}
|
||||
{task.automation_display_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import axios from '../api/client';
|
||||
import { Zap, Plus, Trash2, Clock, CheckCircle2, XCircle, ChevronDown, Power } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface PluginInstallation {
|
||||
interface AutomationInstallation {
|
||||
id: string;
|
||||
template_name: string;
|
||||
template_description: string;
|
||||
@@ -12,14 +12,14 @@ interface PluginInstallation {
|
||||
logo_url?: string;
|
||||
}
|
||||
|
||||
interface EventPlugin {
|
||||
interface EventAutomation {
|
||||
id: string;
|
||||
event: number;
|
||||
plugin_installation: number;
|
||||
plugin_name: string;
|
||||
plugin_description: string;
|
||||
plugin_category: string;
|
||||
plugin_logo_url?: string;
|
||||
automation_installation: number;
|
||||
automation_name: string;
|
||||
automation_description: string;
|
||||
automation_category: string;
|
||||
automation_logo_url?: string;
|
||||
trigger: string;
|
||||
trigger_display: string;
|
||||
offset_minutes: number;
|
||||
@@ -64,41 +64,41 @@ const OFFSET_PRESETS: OffsetPreset[] = [
|
||||
const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact = false }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<string>('');
|
||||
const [selectedAutomation, setSelectedAutomation] = useState<string>('');
|
||||
const [selectedTrigger, setSelectedTrigger] = useState<string>('at_start');
|
||||
const [selectedOffset, setSelectedOffset] = useState<number>(0);
|
||||
|
||||
// Fetch installed plugins
|
||||
const { data: plugins = [] } = useQuery<PluginInstallation[]>({
|
||||
queryKey: ['plugin-installations'],
|
||||
// Fetch installed automations
|
||||
const { data: automations = [] } = useQuery<AutomationInstallation[]>({
|
||||
queryKey: ['automation-installations'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('/plugin-installations/');
|
||||
const { data } = await axios.get('/automation-installations/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch event plugins
|
||||
const { data: eventPlugins = [], isLoading } = useQuery<EventPlugin[]>({
|
||||
queryKey: ['event-plugins', eventId],
|
||||
// Fetch event automations
|
||||
const { data: eventAutomations = [], isLoading } = useQuery<EventAutomation[]>({
|
||||
queryKey: ['event-automations', eventId],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get(`/event-plugins/?event_id=${eventId}`);
|
||||
const { data } = await axios.get(`/event-automations/?event_id=${eventId}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!eventId,
|
||||
});
|
||||
|
||||
// Add plugin mutation
|
||||
// Add automation mutation
|
||||
const addMutation = useMutation({
|
||||
mutationFn: async (data: { plugin_installation: string; trigger: string; offset_minutes: number }) => {
|
||||
return axios.post('/event-plugins/', {
|
||||
mutationFn: async (data: { automation_installation: string; trigger: string; offset_minutes: number }) => {
|
||||
return axios.post('/event-automations/', {
|
||||
event: eventId,
|
||||
...data,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-automations', eventId] });
|
||||
setShowAddForm(false);
|
||||
setSelectedPlugin('');
|
||||
setSelectedAutomation('');
|
||||
setSelectedTrigger('at_start');
|
||||
setSelectedOffset(0);
|
||||
toast.success('Automation added');
|
||||
@@ -110,29 +110,29 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
|
||||
// Toggle mutation
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async (pluginId: string) => {
|
||||
return axios.post(`/event-plugins/${pluginId}/toggle/`);
|
||||
mutationFn: async (automationId: string) => {
|
||||
return axios.post(`/event-automations/${automationId}/toggle/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-automations', eventId] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (pluginId: string) => {
|
||||
return axios.delete(`/event-plugins/${pluginId}/`);
|
||||
mutationFn: async (automationId: string) => {
|
||||
return axios.delete(`/event-automations/${automationId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-plugins', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-automations', eventId] });
|
||||
toast.success('Automation removed');
|
||||
},
|
||||
});
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selectedPlugin) return;
|
||||
if (!selectedAutomation) return;
|
||||
addMutation.mutate({
|
||||
plugin_installation: selectedPlugin,
|
||||
automation_installation: selectedAutomation,
|
||||
trigger: selectedTrigger,
|
||||
offset_minutes: selectedOffset,
|
||||
});
|
||||
@@ -157,13 +157,13 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Automations
|
||||
</span>
|
||||
{eventPlugins.length > 0 && (
|
||||
{eventAutomations.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
|
||||
{eventPlugins.length}
|
||||
{eventAutomations.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!showAddForm && plugins.length > 0 && (
|
||||
{!showAddForm && automations.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-purple-600 dark:text-purple-400 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded transition-colors"
|
||||
@@ -175,9 +175,9 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
</div>
|
||||
|
||||
{/* Existing automations */}
|
||||
{eventPlugins.length > 0 && (
|
||||
{eventAutomations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{eventPlugins.map((ep) => (
|
||||
{eventAutomations.map((ep) => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className={`flex items-center justify-between p-2 rounded-lg border ${
|
||||
@@ -192,7 +192,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{ep.plugin_name}
|
||||
{ep.automation_name}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
@@ -229,20 +229,20 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
{/* Add automation form */}
|
||||
{showAddForm && (
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800 space-y-3">
|
||||
{/* Plugin selector */}
|
||||
{/* Automation selector */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Plugin
|
||||
Automation
|
||||
</label>
|
||||
<select
|
||||
value={selectedPlugin}
|
||||
onChange={(e) => setSelectedPlugin(e.target.value)}
|
||||
value={selectedAutomation}
|
||||
onChange={(e) => setSelectedAutomation(e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="">Select a plugin...</option>
|
||||
{plugins.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.template_name}
|
||||
<option value="">Select an automation...</option>
|
||||
{automations.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.template_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -302,7 +302,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{selectedPlugin && (
|
||||
{selectedAutomation && (
|
||||
<div className="text-xs text-purple-800 dark:text-purple-200 bg-purple-100 dark:bg-purple-900/40 px-2 py-1.5 rounded">
|
||||
Will run: <strong>
|
||||
{selectedTrigger === 'on_complete' ? 'When completed' :
|
||||
@@ -328,7 +328,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!selectedPlugin || addMutation.isPending}
|
||||
disabled={!selectedAutomation || addMutation.isPending}
|
||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{addMutation.isPending ? 'Adding...' : 'Add Automation'}
|
||||
@@ -338,9 +338,9 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{eventPlugins.length === 0 && !showAddForm && (
|
||||
{eventAutomations.length === 0 && !showAddForm && (
|
||||
<div className="text-center py-3 text-gray-500 dark:text-gray-400">
|
||||
{plugins.length > 0 ? (
|
||||
{automations.length > 0 ? (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="text-xs text-purple-600 dark:text-purple-400 hover:underline"
|
||||
@@ -349,7 +349,7 @@ const EventAutomations: React.FC<EventAutomationsProps> = ({ eventId, compact =
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-xs">
|
||||
Install plugins from the Marketplace to use automations
|
||||
Install automations from the Marketplace to use them
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,10 +27,10 @@ const routeToHelpPath: Record<string, string> = {
|
||||
'/payments': '/help/payments',
|
||||
'/contracts': '/help/contracts',
|
||||
'/contracts/templates': '/help/contracts',
|
||||
'/plugins': '/help/plugins',
|
||||
'/plugins/marketplace': '/help/plugins',
|
||||
'/plugins/my-plugins': '/help/plugins',
|
||||
'/plugins/create': '/help/plugins/docs',
|
||||
'/automations': '/help/automations',
|
||||
'/automations/marketplace': '/help/automations',
|
||||
'/automations/my-automations': '/help/automations',
|
||||
'/automations/create': '/help/automations/docs',
|
||||
'/settings': '/help/settings/general',
|
||||
'/settings/general': '/help/settings/general',
|
||||
'/settings/resource-types': '/help/settings/resource-types',
|
||||
|
||||
@@ -130,7 +130,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={Clock}
|
||||
label={t('nav.tasks', 'Tasks')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins') || !canUse('tasks')}
|
||||
locked={!canUse('automations') || !canUse('tasks')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
@@ -257,15 +257,15 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{/* Extend Section - Plugins */}
|
||||
{/* Extend Section - Automations */}
|
||||
{canViewAdminPages && (
|
||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/plugins/my-plugins"
|
||||
to="/dashboard/automations/my-automations"
|
||||
icon={Plug}
|
||||
label={t('nav.plugins', 'Plugins')}
|
||||
label={t('nav.automations', 'Automations')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('plugins')}
|
||||
locked={!canUse('automations')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
@@ -108,7 +108,7 @@ const CapacityWidget: React.FC<CapacityWidgetProps> = ({
|
||||
<p className="text-sm">{t('dashboard.noResourcesConfigured')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min overflow-y-auto">
|
||||
{capacityData.resources.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
|
||||
@@ -59,7 +59,7 @@ const ChartWidget: React.FC<ChartWidgetProps> = ({
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={100} minHeight={100}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
|
||||
@@ -58,7 +58,7 @@ const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
|
||||
<div className="flex-1 flex items-center gap-3 min-h-0">
|
||||
{/* Pie Chart */}
|
||||
<div className="w-20 h-20 flex-shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={60} minHeight={60}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={breakdownData.chartData}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Mail, Calendar, Bell, ArrowRight, Zap, CheckCircle2, Code, LayoutGrid }
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeBlock from './CodeBlock';
|
||||
|
||||
const PluginShowcase: React.FC = () => {
|
||||
const AutomationShowcase: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<'marketplace' | 'code'>('marketplace');
|
||||
@@ -174,7 +174,7 @@ const PluginShowcase: React.FC = () => {
|
||||
// Code View
|
||||
<CodeBlock
|
||||
code={examples[activeTab].code}
|
||||
filename={`${examples[activeTab].id}_plugin.py`}
|
||||
filename={`${examples[activeTab].id}_automation.py`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -194,4 +194,4 @@ const PluginShowcase: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginShowcase;
|
||||
export default AutomationShowcase;
|
||||
@@ -122,11 +122,11 @@
|
||||
"platformGuide": "Platform Guide",
|
||||
"ticketingHelp": "Ticketing System",
|
||||
"apiDocs": "API Docs",
|
||||
"pluginDocs": "Plugin Docs",
|
||||
"automationDocs": "Automation Docs",
|
||||
"contactSupport": "Contact Support",
|
||||
"plugins": "Plugins",
|
||||
"pluginMarketplace": "Marketplace",
|
||||
"myPlugins": "My Plugins",
|
||||
"automations": "Automations",
|
||||
"automationMarketplace": "Marketplace",
|
||||
"myAutomations": "My Automations",
|
||||
"expandSidebar": "Expand sidebar",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"smoothSchedule": "Smooth Schedule",
|
||||
@@ -1481,10 +1481,10 @@
|
||||
"customization": "Customization",
|
||||
"customDomains": "Custom Domains",
|
||||
"whiteLabelling": "White Labelling",
|
||||
"pluginsAutomation": "Plugins & Automation",
|
||||
"usePlugins": "Use Plugins",
|
||||
"automationsSection": "Automations",
|
||||
"useAutomations": "Use Automations",
|
||||
"scheduledTasks": "Scheduled Tasks",
|
||||
"createPlugins": "Create Plugins",
|
||||
"createAutomations": "Create Automations",
|
||||
"advancedFeatures": "Advanced Features",
|
||||
"apiAccess": "API Access",
|
||||
"webhooks": "Webhooks",
|
||||
@@ -2178,14 +2178,14 @@
|
||||
"plugins": {
|
||||
"badge": "Limitless Automation",
|
||||
"headline": "Choose from our Marketplace, or build your own.",
|
||||
"subheadline": "Browse hundreds of pre-built plugins to automate your workflows instantly. Need something custom? Developers can write Python scripts to extend the platform endlessly.",
|
||||
"subheadline": "Browse hundreds of pre-built automations to streamline your workflows instantly. Need something custom? Developers can write Python scripts to extend the platform endlessly.",
|
||||
"viewToggle": {
|
||||
"marketplace": "Marketplace",
|
||||
"developer": "Developer"
|
||||
},
|
||||
"marketplaceCard": {
|
||||
"author": "by SmoothSchedule Team",
|
||||
"installButton": "Install Plugin",
|
||||
"installButton": "Install Automation",
|
||||
"usedBy": "Used by 1,200+ businesses"
|
||||
},
|
||||
"cta": "Explore the Marketplace",
|
||||
@@ -2231,7 +2231,7 @@
|
||||
},
|
||||
"automationEngine": {
|
||||
"title": "Automation Engine",
|
||||
"description": "Install plugins from our marketplace or build your own to automate tasks."
|
||||
"description": "Install automations from our marketplace or build your own to automate tasks."
|
||||
},
|
||||
"multiTenant": {
|
||||
"title": "Enterprise Security",
|
||||
@@ -2278,6 +2278,241 @@
|
||||
"company": "FitNation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"privacyPolicy": {
|
||||
"title": "Privacy Policy",
|
||||
"lastUpdated": "Last updated: December 1, 2025",
|
||||
"section1": {
|
||||
"title": "1. Introduction",
|
||||
"content": "Welcome to SmoothSchedule. We respect your privacy and are committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our scheduling platform and services."
|
||||
},
|
||||
"section2": {
|
||||
"title": "2. Information We Collect",
|
||||
"subsection1": {
|
||||
"title": "2.1 Information You Provide",
|
||||
"intro": "We collect information you directly provide to us, including:",
|
||||
"items": [
|
||||
"Account information (name, email, password, phone number)",
|
||||
"Business information (business name, subdomain, industry)",
|
||||
"Payment information (processed securely through third-party payment processors)",
|
||||
"Customer data you input into the platform (appointments, resources, services)",
|
||||
"Communications with our support team"
|
||||
]
|
||||
},
|
||||
"subsection2": {
|
||||
"title": "2.2 Automatically Collected Information",
|
||||
"intro": "When you use our Service, we automatically collect:",
|
||||
"items": [
|
||||
"Log data (IP address, browser type, device information, operating system)",
|
||||
"Usage data (pages visited, features used, time spent on platform)",
|
||||
"Cookie data (session cookies, preference cookies)",
|
||||
"Performance and error data for service improvement"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section3": {
|
||||
"title": "3. How We Use Your Information",
|
||||
"intro": "We use the collected information for:",
|
||||
"items": [
|
||||
"Providing and maintaining the Service",
|
||||
"Processing your transactions and managing subscriptions",
|
||||
"Sending you service updates, security alerts, and administrative messages",
|
||||
"Responding to your inquiries and providing customer support",
|
||||
"Improving and optimizing our Service",
|
||||
"Detecting and preventing fraud and security issues",
|
||||
"Complying with legal obligations",
|
||||
"Sending marketing communications (with your consent)"
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "4. Data Sharing and Disclosure",
|
||||
"subsection1": {
|
||||
"title": "4.1 We Share Data With:",
|
||||
"items": [
|
||||
"<strong>Service Providers:</strong> Third-party vendors who help us provide the Service (hosting, payment processing, analytics)",
|
||||
"<strong>Business Transfers:</strong> In connection with any merger, sale, or acquisition of all or part of our company",
|
||||
"<strong>Legal Requirements:</strong> When required by law, court order, or legal process",
|
||||
"<strong>Protection of Rights:</strong> To protect our rights, property, or safety, or that of our users"
|
||||
]
|
||||
},
|
||||
"subsection2": {
|
||||
"title": "4.2 We Do NOT:",
|
||||
"items": [
|
||||
"Sell your personal data to third parties",
|
||||
"Share your data for third-party marketing without consent",
|
||||
"Access your customer data except for support or technical purposes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section5": {
|
||||
"title": "5. Data Security",
|
||||
"intro": "We implement industry-standard security measures to protect your data:",
|
||||
"items": [
|
||||
"Encryption of data in transit (TLS/SSL)",
|
||||
"Encryption of sensitive data at rest",
|
||||
"Regular security audits and vulnerability assessments",
|
||||
"Access controls and authentication mechanisms",
|
||||
"Regular backups and disaster recovery procedures"
|
||||
],
|
||||
"disclaimer": "However, no method of transmission over the Internet is 100% secure. While we strive to protect your data, we cannot guarantee absolute security."
|
||||
},
|
||||
"section6": {
|
||||
"title": "6. Data Retention",
|
||||
"content": "We retain your personal data for as long as necessary to provide the Service and fulfill the purposes described in this policy. When you cancel your account, we retain your data for 30 days to allow for account reactivation. After this period, your personal data may be anonymized and aggregated for internal analytics and service improvement purposes. Anonymized data cannot be used to identify you personally and cannot be retrieved or attributed to any person or account. We may also retain certain data if required for legal or legitimate business purposes."
|
||||
},
|
||||
"section7": {
|
||||
"title": "7. Your Rights and Choices",
|
||||
"intro": "Depending on your location, you may have the following rights:",
|
||||
"items": [
|
||||
"<strong>Access:</strong> Request a copy of your personal data",
|
||||
"<strong>Correction:</strong> Update or correct inaccurate data",
|
||||
"<strong>Deletion:</strong> Request deletion of your personal data",
|
||||
"<strong>Portability:</strong> Receive your data in a portable format",
|
||||
"<strong>Objection:</strong> Object to certain data processing activities",
|
||||
"<strong>Restriction:</strong> Request restriction of data processing",
|
||||
"<strong>Withdraw Consent:</strong> Withdraw previously given consent"
|
||||
],
|
||||
"contact": "To exercise these rights, please contact us at privacy@smoothschedule.com."
|
||||
},
|
||||
"section8": {
|
||||
"title": "8. Cookies and Tracking",
|
||||
"intro": "We use cookies and similar tracking technologies to:",
|
||||
"items": [
|
||||
"Maintain your session and keep you logged in",
|
||||
"Remember your preferences and settings",
|
||||
"Analyze usage patterns and improve our Service",
|
||||
"Provide personalized content and features"
|
||||
],
|
||||
"disclaimer": "You can control cookies through your browser settings, but disabling cookies may affect your ability to use certain features of the Service."
|
||||
},
|
||||
"section9": {
|
||||
"title": "9. Third-Party Services",
|
||||
"content": "Our Service may contain links to third-party websites or integrate with third-party services (OAuth providers, payment processors). We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information."
|
||||
},
|
||||
"section10": {
|
||||
"title": "10. Children's Privacy",
|
||||
"content": "Our Service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have collected data from a child under 13, please contact us immediately so we can delete it."
|
||||
},
|
||||
"section11": {
|
||||
"title": "11. International Data Transfers",
|
||||
"content": "Your information may be transferred to and processed in countries other than your country of residence. These countries may have different data protection laws. We ensure appropriate safeguards are in place to protect your data in accordance with this Privacy Policy."
|
||||
},
|
||||
"section12": {
|
||||
"title": "12. California Privacy Rights",
|
||||
"content": "If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA), including the right to know what personal information we collect, the right to delete your information, and the right to opt-out of the sale of your information (which we do not do)."
|
||||
},
|
||||
"section13": {
|
||||
"title": "13. GDPR Compliance",
|
||||
"content": "If you are in the European Economic Area (EEA), we process your personal data based on legal grounds such as consent, contract performance, legal obligations, or legitimate interests. You have rights under the General Data Protection Regulation (GDPR) including the right to lodge a complaint with a supervisory authority."
|
||||
},
|
||||
"section14": {
|
||||
"title": "14. Changes to This Privacy Policy",
|
||||
"content": "We may update this Privacy Policy from time to time. We will notify you of material changes by posting the new policy on this page and updating the \"Last updated\" date. We encourage you to review this Privacy Policy periodically."
|
||||
},
|
||||
"section15": {
|
||||
"title": "15. Contact Us",
|
||||
"intro": "If you have any questions about this Privacy Policy or our data practices, please contact us:",
|
||||
"emailLabel": "Email:",
|
||||
"email": "privacy@smoothschedule.com",
|
||||
"dpoLabel": "Data Protection Officer:",
|
||||
"dpo": "dpo@smoothschedule.com",
|
||||
"websiteLabel": "Website:",
|
||||
"website": "https://smoothschedule.com/contact"
|
||||
}
|
||||
},
|
||||
"termsOfService": {
|
||||
"title": "Terms of Service",
|
||||
"lastUpdated": "Last updated: December 1, 2025",
|
||||
"sections": {
|
||||
"acceptanceOfTerms": {
|
||||
"title": "1. Acceptance of Terms",
|
||||
"content": "By accessing and using SmoothSchedule (\"the Service\"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to these Terms of Service, please do not use the Service."
|
||||
},
|
||||
"descriptionOfService": {
|
||||
"title": "2. Description of Service",
|
||||
"content": "SmoothSchedule is a scheduling platform that enables businesses to manage appointments, resources, services, and customer interactions. The Service is provided on a subscription basis with various pricing tiers."
|
||||
},
|
||||
"userAccounts": {
|
||||
"title": "3. User Accounts",
|
||||
"intro": "To use the Service, you must:",
|
||||
"requirements": {
|
||||
"accurate": "Create an account with accurate and complete information",
|
||||
"security": "Maintain the security of your account credentials",
|
||||
"notify": "Notify us immediately of any unauthorized access",
|
||||
"responsible": "Be responsible for all activities under your account"
|
||||
}
|
||||
},
|
||||
"acceptableUse": {
|
||||
"title": "4. Acceptable Use",
|
||||
"intro": "You agree not to use the Service to:",
|
||||
"prohibitions": {
|
||||
"laws": "Violate any applicable laws or regulations",
|
||||
"ip": "Infringe on intellectual property rights",
|
||||
"malicious": "Transmit malicious code or interfere with the Service",
|
||||
"unauthorized": "Attempt to gain unauthorized access to any part of the Service",
|
||||
"fraudulent": "Use the Service for any fraudulent or illegal purpose"
|
||||
}
|
||||
},
|
||||
"subscriptionsAndPayments": {
|
||||
"title": "5. Subscriptions and Payments",
|
||||
"intro": "Subscription terms:",
|
||||
"terms": {
|
||||
"billing": "Subscriptions are billed in advance on a recurring basis",
|
||||
"cancel": "You may cancel your subscription at any time",
|
||||
"refunds": "No refunds are provided for partial subscription periods",
|
||||
"pricing": "We reserve the right to change pricing with 30 days notice",
|
||||
"failed": "Failed payments may result in service suspension"
|
||||
}
|
||||
},
|
||||
"trialPeriod": {
|
||||
"title": "6. Trial Period",
|
||||
"content": "We may offer a free trial period. At the end of the trial, your subscription will automatically convert to a paid plan unless you cancel. Trial terms may vary and are subject to change."
|
||||
},
|
||||
"dataAndPrivacy": {
|
||||
"title": "7. Data and Privacy",
|
||||
"content": "Your use of the Service is also governed by our Privacy Policy. We collect, use, and protect your data as described in that policy. You retain ownership of all data you input into the Service."
|
||||
},
|
||||
"serviceAvailability": {
|
||||
"title": "8. Service Availability",
|
||||
"content": "While we strive for 99.9% uptime, we do not guarantee uninterrupted access to the Service. We may perform maintenance, updates, or modifications that temporarily affect availability. We are not liable for any downtime or service interruptions."
|
||||
},
|
||||
"intellectualProperty": {
|
||||
"title": "9. Intellectual Property",
|
||||
"content": "The Service, including all content, features, and functionality, is owned by SmoothSchedule and protected by copyright, trademark, and other intellectual property laws. You may not copy, modify, distribute, or create derivative works without our express written consent."
|
||||
},
|
||||
"warrantyDisclaimer": {
|
||||
"title": "10. Warranty Disclaimer",
|
||||
"content": "THE SERVICE IS PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED. WE DISCLAIM ALL WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT."
|
||||
},
|
||||
"limitationOfLiability": {
|
||||
"title": "11. Limitation of Liability",
|
||||
"content": "IN NO EVENT SHALL SMOOTHSCHEDULE BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES. OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT YOU PAID US IN THE TWELVE MONTHS PRECEDING THE CLAIM."
|
||||
},
|
||||
"indemnification": {
|
||||
"title": "12. Indemnification",
|
||||
"content": "You agree to indemnify and hold harmless SmoothSchedule and its officers, directors, employees, and agents from any claims, damages, losses, or expenses arising from your use of the Service or violation of these Terms."
|
||||
},
|
||||
"termination": {
|
||||
"title": "10. Termination",
|
||||
"content": "We may suspend or terminate your access to the Service at any time for any reason, including violation of these Terms. Upon termination, your right to use the Service ceases immediately. Provisions that by their nature should survive termination will remain in effect."
|
||||
},
|
||||
"governingLaw": {
|
||||
"title": "14. Governing Law",
|
||||
"content": "These Terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to its conflict of law provisions. Any disputes shall be resolved in the courts of Delaware."
|
||||
},
|
||||
"changesToTerms": {
|
||||
"title": "15. Changes to Terms",
|
||||
"content": "We may update these Terms from time to time. We will notify you of material changes by posting the new Terms on this page and updating the \"Last updated\" date. Your continued use of the Service after changes constitutes acceptance of the new Terms."
|
||||
},
|
||||
"contactUs": {
|
||||
"title": "16. Contact Us",
|
||||
"intro": "If you have any questions about these Terms of Service, please contact us:",
|
||||
"emailLabel": "Email:",
|
||||
"email": "legal@smoothschedule.com",
|
||||
"websiteLabel": "Website:",
|
||||
"website": "https://smoothschedule.com/contact"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"trial": {
|
||||
@@ -2487,241 +2722,6 @@
|
||||
"paymentProcessing": "Payment processing",
|
||||
"prioritySupport": "Priority support"
|
||||
}
|
||||
},
|
||||
"privacyPolicy": {
|
||||
"title": "Privacy Policy",
|
||||
"lastUpdated": "Last updated: December 1, 2025",
|
||||
"section1": {
|
||||
"title": "1. Introduction",
|
||||
"content": "Welcome to SmoothSchedule. We respect your privacy and are committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our scheduling platform and services."
|
||||
},
|
||||
"section2": {
|
||||
"title": "2. Information We Collect",
|
||||
"subsection1": {
|
||||
"title": "2.1 Information You Provide",
|
||||
"intro": "We collect information you directly provide to us, including:",
|
||||
"items": [
|
||||
"Account information (name, email, password, phone number)",
|
||||
"Business information (business name, subdomain, industry)",
|
||||
"Payment information (processed securely through third-party payment processors)",
|
||||
"Customer data you input into the platform (appointments, resources, services)",
|
||||
"Communications with our support team"
|
||||
]
|
||||
},
|
||||
"subsection2": {
|
||||
"title": "2.2 Automatically Collected Information",
|
||||
"intro": "When you use our Service, we automatically collect:",
|
||||
"items": [
|
||||
"Log data (IP address, browser type, device information, operating system)",
|
||||
"Usage data (pages visited, features used, time spent on platform)",
|
||||
"Cookie data (session cookies, preference cookies)",
|
||||
"Performance and error data for service improvement"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section3": {
|
||||
"title": "3. How We Use Your Information",
|
||||
"intro": "We use the collected information for:",
|
||||
"items": [
|
||||
"Providing and maintaining the Service",
|
||||
"Processing your transactions and managing subscriptions",
|
||||
"Sending you service updates, security alerts, and administrative messages",
|
||||
"Responding to your inquiries and providing customer support",
|
||||
"Improving and optimizing our Service",
|
||||
"Detecting and preventing fraud and security issues",
|
||||
"Complying with legal obligations",
|
||||
"Sending marketing communications (with your consent)"
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "4. Data Sharing and Disclosure",
|
||||
"subsection1": {
|
||||
"title": "4.1 We Share Data With:",
|
||||
"items": [
|
||||
"<strong>Service Providers:</strong> Third-party vendors who help us provide the Service (hosting, payment processing, analytics)",
|
||||
"<strong>Business Transfers:</strong> In connection with any merger, sale, or acquisition of all or part of our company",
|
||||
"<strong>Legal Requirements:</strong> When required by law, court order, or legal process",
|
||||
"<strong>Protection of Rights:</strong> To protect our rights, property, or safety, or that of our users"
|
||||
]
|
||||
},
|
||||
"subsection2": {
|
||||
"title": "4.2 We Do NOT:",
|
||||
"items": [
|
||||
"Sell your personal data to third parties",
|
||||
"Share your data for third-party marketing without consent",
|
||||
"Access your customer data except for support or technical purposes"
|
||||
]
|
||||
}
|
||||
},
|
||||
"section5": {
|
||||
"title": "5. Data Security",
|
||||
"intro": "We implement industry-standard security measures to protect your data:",
|
||||
"items": [
|
||||
"Encryption of data in transit (TLS/SSL)",
|
||||
"Encryption of sensitive data at rest",
|
||||
"Regular security audits and vulnerability assessments",
|
||||
"Access controls and authentication mechanisms",
|
||||
"Regular backups and disaster recovery procedures"
|
||||
],
|
||||
"disclaimer": "However, no method of transmission over the Internet is 100% secure. While we strive to protect your data, we cannot guarantee absolute security."
|
||||
},
|
||||
"section6": {
|
||||
"title": "6. Data Retention",
|
||||
"content": "We retain your personal data for as long as necessary to provide the Service and fulfill the purposes described in this policy. When you cancel your account, we retain your data for 30 days to allow for account reactivation. After this period, your personal data may be anonymized and aggregated for internal analytics and service improvement purposes. Anonymized data cannot be used to identify you personally and cannot be retrieved or attributed to any person or account. We may also retain certain data if required for legal or legitimate business purposes."
|
||||
},
|
||||
"section7": {
|
||||
"title": "7. Your Rights and Choices",
|
||||
"intro": "Depending on your location, you may have the following rights:",
|
||||
"items": [
|
||||
"<strong>Access:</strong> Request a copy of your personal data",
|
||||
"<strong>Correction:</strong> Update or correct inaccurate data",
|
||||
"<strong>Deletion:</strong> Request deletion of your personal data",
|
||||
"<strong>Portability:</strong> Receive your data in a portable format",
|
||||
"<strong>Objection:</strong> Object to certain data processing activities",
|
||||
"<strong>Restriction:</strong> Request restriction of data processing",
|
||||
"<strong>Withdraw Consent:</strong> Withdraw previously given consent"
|
||||
],
|
||||
"contact": "To exercise these rights, please contact us at privacy@smoothschedule.com."
|
||||
},
|
||||
"section8": {
|
||||
"title": "8. Cookies and Tracking",
|
||||
"intro": "We use cookies and similar tracking technologies to:",
|
||||
"items": [
|
||||
"Maintain your session and keep you logged in",
|
||||
"Remember your preferences and settings",
|
||||
"Analyze usage patterns and improve our Service",
|
||||
"Provide personalized content and features"
|
||||
],
|
||||
"disclaimer": "You can control cookies through your browser settings, but disabling cookies may affect your ability to use certain features of the Service."
|
||||
},
|
||||
"section9": {
|
||||
"title": "9. Third-Party Services",
|
||||
"content": "Our Service may contain links to third-party websites or integrate with third-party services (OAuth providers, payment processors). We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information."
|
||||
},
|
||||
"section10": {
|
||||
"title": "10. Children's Privacy",
|
||||
"content": "Our Service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have collected data from a child under 13, please contact us immediately so we can delete it."
|
||||
},
|
||||
"section11": {
|
||||
"title": "11. International Data Transfers",
|
||||
"content": "Your information may be transferred to and processed in countries other than your country of residence. These countries may have different data protection laws. We ensure appropriate safeguards are in place to protect your data in accordance with this Privacy Policy."
|
||||
},
|
||||
"section12": {
|
||||
"title": "12. California Privacy Rights",
|
||||
"content": "If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA), including the right to know what personal information we collect, the right to delete your information, and the right to opt-out of the sale of your information (which we do not do)."
|
||||
},
|
||||
"section13": {
|
||||
"title": "13. GDPR Compliance",
|
||||
"content": "If you are in the European Economic Area (EEA), we process your personal data based on legal grounds such as consent, contract performance, legal obligations, or legitimate interests. You have rights under the General Data Protection Regulation (GDPR) including the right to lodge a complaint with a supervisory authority."
|
||||
},
|
||||
"section14": {
|
||||
"title": "14. Changes to This Privacy Policy",
|
||||
"content": "We may update this Privacy Policy from time to time. We will notify you of material changes by posting the new policy on this page and updating the \"Last updated\" date. We encourage you to review this Privacy Policy periodically."
|
||||
},
|
||||
"section15": {
|
||||
"title": "15. Contact Us",
|
||||
"intro": "If you have any questions about this Privacy Policy or our data practices, please contact us:",
|
||||
"emailLabel": "Email:",
|
||||
"email": "privacy@smoothschedule.com",
|
||||
"dpoLabel": "Data Protection Officer:",
|
||||
"dpo": "dpo@smoothschedule.com",
|
||||
"websiteLabel": "Website:",
|
||||
"website": "https://smoothschedule.com/contact"
|
||||
}
|
||||
},
|
||||
"termsOfService": {
|
||||
"title": "Terms of Service",
|
||||
"lastUpdated": "Last updated: December 1, 2025",
|
||||
"sections": {
|
||||
"acceptanceOfTerms": {
|
||||
"title": "1. Acceptance of Terms",
|
||||
"content": "By accessing and using SmoothSchedule (\"the Service\"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to these Terms of Service, please do not use the Service."
|
||||
},
|
||||
"descriptionOfService": {
|
||||
"title": "2. Description of Service",
|
||||
"content": "SmoothSchedule is a scheduling platform that enables businesses to manage appointments, resources, services, and customer interactions. The Service is provided on a subscription basis with various pricing tiers."
|
||||
},
|
||||
"userAccounts": {
|
||||
"title": "3. User Accounts",
|
||||
"intro": "To use the Service, you must:",
|
||||
"requirements": {
|
||||
"accurate": "Create an account with accurate and complete information",
|
||||
"security": "Maintain the security of your account credentials",
|
||||
"notify": "Notify us immediately of any unauthorized access",
|
||||
"responsible": "Be responsible for all activities under your account"
|
||||
}
|
||||
},
|
||||
"acceptableUse": {
|
||||
"title": "4. Acceptable Use",
|
||||
"intro": "You agree not to use the Service to:",
|
||||
"prohibitions": {
|
||||
"laws": "Violate any applicable laws or regulations",
|
||||
"ip": "Infringe on intellectual property rights",
|
||||
"malicious": "Transmit malicious code or interfere with the Service",
|
||||
"unauthorized": "Attempt to gain unauthorized access to any part of the Service",
|
||||
"fraudulent": "Use the Service for any fraudulent or illegal purpose"
|
||||
}
|
||||
},
|
||||
"subscriptionsAndPayments": {
|
||||
"title": "5. Subscriptions and Payments",
|
||||
"intro": "Subscription terms:",
|
||||
"terms": {
|
||||
"billing": "Subscriptions are billed in advance on a recurring basis",
|
||||
"cancel": "You may cancel your subscription at any time",
|
||||
"refunds": "No refunds are provided for partial subscription periods",
|
||||
"pricing": "We reserve the right to change pricing with 30 days notice",
|
||||
"failed": "Failed payments may result in service suspension"
|
||||
}
|
||||
},
|
||||
"trialPeriod": {
|
||||
"title": "6. Trial Period",
|
||||
"content": "We may offer a free trial period. At the end of the trial, your subscription will automatically convert to a paid plan unless you cancel. Trial terms may vary and are subject to change."
|
||||
},
|
||||
"dataAndPrivacy": {
|
||||
"title": "7. Data and Privacy",
|
||||
"content": "Your use of the Service is also governed by our Privacy Policy. We collect, use, and protect your data as described in that policy. You retain ownership of all data you input into the Service."
|
||||
},
|
||||
"serviceAvailability": {
|
||||
"title": "8. Service Availability",
|
||||
"content": "While we strive for 99.9% uptime, we do not guarantee uninterrupted access to the Service. We may perform maintenance, updates, or modifications that temporarily affect availability. We are not liable for any downtime or service interruptions."
|
||||
},
|
||||
"intellectualProperty": {
|
||||
"title": "9. Intellectual Property",
|
||||
"content": "The Service, including all software, designs, text, graphics, and other content, is owned by SmoothSchedule and protected by copyright, trademark, and other intellectual property laws. You may not copy, modify, distribute, or create derivative works without our express written permission."
|
||||
},
|
||||
"termination": {
|
||||
"title": "10. Termination",
|
||||
"content": "We may terminate or suspend your account and access to the Service at any time, with or without cause, with or without notice. Upon termination, your right to use the Service will immediately cease. We will retain your data for 30 days after termination, after which it may be permanently deleted."
|
||||
},
|
||||
"limitationOfLiability": {
|
||||
"title": "11. Limitation of Liability",
|
||||
"content": "To the maximum extent permitted by law, SmoothSchedule shall not be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses resulting from your use of the Service."
|
||||
},
|
||||
"warrantyDisclaimer": {
|
||||
"title": "12. Warranty Disclaimer",
|
||||
"content": "The Service is provided \"as is\" and \"as available\" without warranties of any kind, either express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, or non-infringement."
|
||||
},
|
||||
"indemnification": {
|
||||
"title": "13. Indemnification",
|
||||
"content": "You agree to indemnify and hold harmless SmoothSchedule, its officers, directors, employees, and agents from any claims, damages, losses, liabilities, and expenses (including legal fees) arising from your use of the Service or violation of these Terms."
|
||||
},
|
||||
"changesToTerms": {
|
||||
"title": "14. Changes to Terms",
|
||||
"content": "We reserve the right to modify these Terms at any time. We will notify you of material changes via email or through the Service. Your continued use of the Service after such changes constitutes acceptance of the new Terms."
|
||||
},
|
||||
"governingLaw": {
|
||||
"title": "15. Governing Law",
|
||||
"content": "These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which SmoothSchedule is registered, without regard to its conflict of law provisions."
|
||||
},
|
||||
"contactUs": {
|
||||
"title": "16. Contact Us",
|
||||
"intro": "If you have any questions about these Terms of Service, please contact us at:",
|
||||
"email": "Email:",
|
||||
"emailAddress": "legal@smoothschedule.com",
|
||||
"website": "Website:",
|
||||
"websiteUrl": "https://smoothschedule.com/contact"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"timeBlocks": {
|
||||
@@ -3121,9 +3121,9 @@
|
||||
"timeBlocksDocumentationDesc": "Complete guide to creating, managing, and visualizing time blocks"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
"description": "Plugins extend SmoothSchedule with custom automation and integrations. Browse the marketplace for pre-built plugins or create your own using our scripting language.",
|
||||
"whatPluginsCanDo": "What Plugins Can Do",
|
||||
"title": "Automations",
|
||||
"description": "Automations extend SmoothSchedule with custom workflows and integrations. Browse the marketplace for pre-built automations or create your own using our scripting language.",
|
||||
"whatPluginsCanDo": "What Automations Can Do",
|
||||
"sendEmailsCapability": "Send Emails:",
|
||||
"sendEmailsDesc": "Automated reminders, confirmations, and follow-ups",
|
||||
"webhooksCapability": "Webhooks:",
|
||||
@@ -3132,20 +3132,20 @@
|
||||
"reportsDesc": "Generate and email business reports on a schedule",
|
||||
"cleanupCapability": "Cleanup:",
|
||||
"cleanupDesc": "Automatically archive old data or manage records",
|
||||
"pluginTypes": "Plugin Types",
|
||||
"marketplacePlugins": "Marketplace Plugins",
|
||||
"marketplacePluginsDesc": "Pre-built plugins available to install immediately. Browse, install, and configure with a few clicks.",
|
||||
"customPlugins": "Custom Plugins",
|
||||
"customPluginsDesc": "Create your own plugins using our scripting language. Full control over logic and triggers.",
|
||||
"pluginTypes": "Automation Types",
|
||||
"marketplacePlugins": "Marketplace Automations",
|
||||
"marketplacePluginsDesc": "Pre-built automations available to install immediately. Browse, install, and configure with a few clicks.",
|
||||
"customPlugins": "Custom Automations",
|
||||
"customPluginsDesc": "Create your own automations using our scripting language. Full control over logic and triggers.",
|
||||
"triggers": "Triggers",
|
||||
"triggersDesc": "Plugins can be triggered in various ways:",
|
||||
"triggersDesc": "Automations can be triggered in various ways:",
|
||||
"beforeEventTrigger": "Before Event",
|
||||
"atStartTrigger": "At Start",
|
||||
"afterEndTrigger": "After End",
|
||||
"onStatusChangeTrigger": "On Status Change",
|
||||
"learnMore": "Learn More",
|
||||
"pluginDocumentation": "Plugin Documentation",
|
||||
"pluginDocumentationDesc": "Complete guide to creating and using plugins, including API reference and examples"
|
||||
"pluginDocumentation": "Automation Documentation",
|
||||
"pluginDocumentationDesc": "Complete guide to creating and using automations, including API reference and examples"
|
||||
},
|
||||
"contracts": {
|
||||
"title": "Contracts",
|
||||
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import api from '../api/client';
|
||||
import { PluginTemplate, PluginCategory } from '../types';
|
||||
import { AutomationTemplate, AutomationCategory } from '../types';
|
||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||
import { LockedSection } from '../components/UpgradePrompt';
|
||||
|
||||
// Category icon mapping
|
||||
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
const categoryIcons: Record<AutomationCategory, React.ReactNode> = {
|
||||
EMAIL: <Mail className="h-4 w-4" />,
|
||||
REPORTS: <BarChart3 className="h-4 w-4" />,
|
||||
CUSTOMER: <Users className="h-4 w-4" />,
|
||||
@@ -44,7 +44,7 @@ const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<PluginCategory, string> = {
|
||||
const categoryColors: Record<AutomationCategory, string> = {
|
||||
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
REPORTS: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
CUSTOMER: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
@@ -80,29 +80,29 @@ const detectPlatformOnlyCode = (code: string): { hasPlatformCode: boolean; warni
|
||||
};
|
||||
};
|
||||
|
||||
const PluginMarketplace: React.FC = () => {
|
||||
const AutomationMarketplace: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginTemplate | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<AutomationCategory | 'ALL'>('ALL');
|
||||
const [selectedAutomation, setSelectedAutomation] = useState<AutomationTemplate | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||
const [showWhatsNextModal, setShowWhatsNextModal] = useState(false);
|
||||
const [installedPluginId, setInstalledPluginId] = useState<string | null>(null);
|
||||
const [installedAutomationId, setInstalledAutomationId] = useState<string | null>(null);
|
||||
|
||||
// Check plan permissions
|
||||
const { canUse, isLoading: permissionsLoading } = usePlanFeatures();
|
||||
const hasPluginsFeature = canUse('plugins');
|
||||
const isLocked = !hasPluginsFeature;
|
||||
const hasAutomationsFeature = canUse('automations');
|
||||
const isLocked = !hasAutomationsFeature;
|
||||
|
||||
// Fetch marketplace plugins - only when user has the feature
|
||||
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
|
||||
queryKey: ['plugin-templates', 'marketplace'],
|
||||
// Fetch marketplace automations - only when user has the feature
|
||||
const { data: automations = [], isLoading, error } = useQuery<AutomationTemplate[]>({
|
||||
queryKey: ['automation-templates', 'marketplace'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/plugin-templates/?view=marketplace');
|
||||
const { data } = await api.get('/automation-templates/?view=marketplace');
|
||||
return data.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
@@ -118,49 +118,49 @@ const PluginMarketplace: React.FC = () => {
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
logoUrl: p.logo_url,
|
||||
pluginCode: p.plugin_code,
|
||||
automationCode: p.automation_code,
|
||||
}));
|
||||
},
|
||||
// Don't fetch if user doesn't have the plugins feature
|
||||
enabled: hasPluginsFeature && !permissionsLoading,
|
||||
// Don't fetch if user doesn't have the automations feature
|
||||
enabled: hasAutomationsFeature && !permissionsLoading,
|
||||
});
|
||||
|
||||
// Fetch installed plugins to check which are already installed
|
||||
const { data: installedPlugins = [] } = useQuery<{ template: number }[]>({
|
||||
queryKey: ['plugin-installations'],
|
||||
// Fetch installed automations to check which are already installed
|
||||
const { data: installedAutomations = [] } = useQuery<{ template: number }[]>({
|
||||
queryKey: ['automation-installations'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/plugin-installations/');
|
||||
const { data } = await api.get('/automation-installations/');
|
||||
return data;
|
||||
},
|
||||
// Don't fetch if user doesn't have the plugins feature
|
||||
enabled: hasPluginsFeature && !permissionsLoading,
|
||||
// Don't fetch if user doesn't have the automations feature
|
||||
enabled: hasAutomationsFeature && !permissionsLoading,
|
||||
});
|
||||
|
||||
// Create a set of installed template IDs for quick lookup
|
||||
const installedTemplateIds = useMemo(() => {
|
||||
return new Set(installedPlugins.map(p => String(p.template)));
|
||||
}, [installedPlugins]);
|
||||
return new Set(installedAutomations.map(p => String(p.template)));
|
||||
}, [installedAutomations]);
|
||||
|
||||
// Install plugin mutation
|
||||
// Install automation mutation
|
||||
const installMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
const { data } = await api.post('/plugin-installations/', {
|
||||
const { data } = await api.post('/automation-installations/', {
|
||||
template: templateId,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
|
||||
setShowDetailsModal(false);
|
||||
// Show What's Next modal
|
||||
setInstalledPluginId(data.id);
|
||||
setInstalledAutomationId(data.id);
|
||||
setShowWhatsNextModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Filter plugins
|
||||
const filteredPlugins = useMemo(() => {
|
||||
let result = plugins;
|
||||
// Filter automations
|
||||
const filteredAutomations = useMemo(() => {
|
||||
let result = automations;
|
||||
|
||||
// Filter by category
|
||||
if (selectedCategory !== 'ALL') {
|
||||
@@ -178,42 +178,42 @@ const PluginMarketplace: React.FC = () => {
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [plugins, selectedCategory, searchQuery]);
|
||||
}, [automations, selectedCategory, searchQuery]);
|
||||
|
||||
// Sort: Featured first, then by rating, then by install count
|
||||
const sortedPlugins = useMemo(() => {
|
||||
return [...filteredPlugins].sort((a, b) => {
|
||||
const sortedAutomations = useMemo(() => {
|
||||
return [...filteredAutomations].sort((a, b) => {
|
||||
if (a.isFeatured && !b.isFeatured) return -1;
|
||||
if (!a.isFeatured && b.isFeatured) return 1;
|
||||
if (a.rating !== b.rating) return b.rating - a.rating;
|
||||
return b.installCount - a.installCount;
|
||||
});
|
||||
}, [filteredPlugins]);
|
||||
}, [filteredAutomations]);
|
||||
|
||||
const handleInstall = async (plugin: PluginTemplate) => {
|
||||
setSelectedPlugin(plugin);
|
||||
const handleInstall = async (automation: AutomationTemplate) => {
|
||||
setSelectedAutomation(automation);
|
||||
setShowDetailsModal(true);
|
||||
setShowCode(false);
|
||||
|
||||
// Fetch full plugin details including plugin_code
|
||||
// Fetch full automation details including automation_code
|
||||
setIsLoadingDetails(true);
|
||||
try {
|
||||
const { data } = await api.get(`/plugin-templates/${plugin.id}/`);
|
||||
setSelectedPlugin({
|
||||
...plugin,
|
||||
pluginCode: data.plugin_code,
|
||||
const { data } = await api.get(`/automation-templates/${automation.id}/`);
|
||||
setSelectedAutomation({
|
||||
...automation,
|
||||
automationCode: data.automation_code,
|
||||
logoUrl: data.logo_url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugin details:', error);
|
||||
console.error('Failed to fetch automation details:', error);
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmInstall = () => {
|
||||
if (selectedPlugin) {
|
||||
installMutation.mutate(selectedPlugin.id);
|
||||
if (selectedAutomation) {
|
||||
installMutation.mutate(selectedAutomation.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,17 +246,17 @@ const PluginMarketplace: React.FC = () => {
|
||||
const effectivelyLocked = isLocked || is403Error;
|
||||
|
||||
return (
|
||||
<LockedSection feature="plugins" isLocked={effectivelyLocked} variant="overlay">
|
||||
<LockedSection feature="automations" isLocked={effectivelyLocked} variant="overlay">
|
||||
<div className="p-8 space-y-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ShoppingBag className="h-7 w-7 text-brand-600" />
|
||||
{t('plugins.marketplace', 'Plugin Marketplace')}
|
||||
{t('automations.marketplace', 'Automation Marketplace')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('plugins.marketplaceDescription', 'Extend your business with powerful plugins')}
|
||||
{t('automations.marketplaceDescription', 'Extend your business with powerful automations')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,7 +271,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('plugins.searchPlugins', 'Search plugins...')}
|
||||
placeholder={t('automations.searchAutomations', 'Search automations...')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
@@ -281,17 +281,17 @@ const PluginMarketplace: React.FC = () => {
|
||||
<Filter className="h-5 w-5 text-gray-400" />
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value as PluginCategory | 'ALL')}
|
||||
onChange={(e) => setSelectedCategory(e.target.value as AutomationCategory | 'ALL')}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
<option value="ALL">{t('plugins.allCategories', 'All Categories')}</option>
|
||||
<option value="EMAIL">{t('plugins.categoryEmail', 'Email')}</option>
|
||||
<option value="REPORTS">{t('plugins.categoryReports', 'Reports')}</option>
|
||||
<option value="CUSTOMER">{t('plugins.categoryCustomer', 'Customer')}</option>
|
||||
<option value="BOOKING">{t('plugins.categoryBooking', 'Booking')}</option>
|
||||
<option value="INTEGRATION">{t('plugins.categoryIntegration', 'Integration')}</option>
|
||||
<option value="AUTOMATION">{t('plugins.categoryAutomation', 'Automation')}</option>
|
||||
<option value="OTHER">{t('plugins.categoryOther', 'Other')}</option>
|
||||
<option value="ALL">{t('automations.allCategories', 'All Categories')}</option>
|
||||
<option value="EMAIL">{t('automations.categoryEmail', 'Email')}</option>
|
||||
<option value="REPORTS">{t('automations.categoryReports', 'Reports')}</option>
|
||||
<option value="CUSTOMER">{t('automations.categoryCustomer', 'Customer')}</option>
|
||||
<option value="BOOKING">{t('automations.categoryBooking', 'Booking')}</option>
|
||||
<option value="INTEGRATION">{t('automations.categoryIntegration', 'Integration')}</option>
|
||||
<option value="AUTOMATION">{t('automations.categoryAutomation', 'Automation')}</option>
|
||||
<option value="OTHER">{t('automations.categoryOther', 'Other')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.showingResults', 'Showing')} {sortedPlugins.length} {t('plugins.results', 'results')}
|
||||
{t('automations.showingResults', 'Showing')} {sortedAutomations.length} {t('automations.results', 'results')}
|
||||
</span>
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<button
|
||||
@@ -317,31 +317,31 @@ const PluginMarketplace: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plugin Grid */}
|
||||
{sortedPlugins.length === 0 ? (
|
||||
{/* Automation Grid */}
|
||||
{sortedAutomations.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<ShoppingBag className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchQuery || selectedCategory !== 'ALL'
|
||||
? t('plugins.noPluginsFound', 'No plugins found matching your criteria')
|
||||
: t('plugins.noPluginsAvailable', 'No plugins available yet')}
|
||||
? t('automations.noAutomationsFound', 'No automations found matching your criteria')
|
||||
: t('automations.noAutomationsAvailable', 'No automations available yet')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedPlugins.map((plugin) => (
|
||||
{sortedAutomations.map((automation) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
key={automation.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Plugin Card Header */}
|
||||
{/* Automation Card Header */}
|
||||
<div className="p-6 flex-1">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{/* Logo */}
|
||||
{plugin.logoUrl ? (
|
||||
{automation.logoUrl ? (
|
||||
<img
|
||||
src={plugin.logoUrl}
|
||||
alt={`${plugin.name} logo`}
|
||||
src={automation.logoUrl}
|
||||
alt={`${automation.name} logo`}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
@@ -353,24 +353,24 @@ const PluginMarketplace: React.FC = () => {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
|
||||
{categoryIcons[plugin.category]}
|
||||
{plugin.category}
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[automation.category]}`}>
|
||||
{categoryIcons[automation.category]}
|
||||
{automation.category}
|
||||
</span>
|
||||
{plugin.isFeatured && (
|
||||
{automation.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-3 w-3" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
{installedTemplateIds.has(plugin.id) && (
|
||||
{installedTemplateIds.has(automation.id) && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.isVerified && (
|
||||
{automation.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500 flex-shrink-0" title="Verified" />
|
||||
)}
|
||||
</div>
|
||||
@@ -378,39 +378,39 @@ const PluginMarketplace: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{plugin.name}
|
||||
{automation.name}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{plugin.description}
|
||||
{automation.description}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500 fill-amber-500" />
|
||||
<span className="font-medium">{plugin.rating.toFixed(1)}</span>
|
||||
<span className="text-xs">({plugin.ratingCount})</span>
|
||||
<span className="font-medium">{automation.rating.toFixed(1)}</span>
|
||||
<span className="text-xs">({automation.ratingCount})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{plugin.installCount.toLocaleString()}</span>
|
||||
<span>{automation.installCount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
by {plugin.author} • v{plugin.version}
|
||||
by {automation.author} • v{automation.version}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plugin Card Actions */}
|
||||
{/* Automation Card Actions */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => handleInstall(plugin)}
|
||||
onClick={() => handleInstall(automation)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{t('plugins.view', 'View')}
|
||||
{t('automations.view', 'View')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -418,18 +418,18 @@ const PluginMarketplace: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plugin Details Modal */}
|
||||
{showDetailsModal && selectedPlugin && (
|
||||
{/* Automation Details Modal */}
|
||||
{showDetailsModal && selectedAutomation && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Logo in modal header */}
|
||||
{selectedPlugin.logoUrl ? (
|
||||
{selectedAutomation.logoUrl ? (
|
||||
<img
|
||||
src={selectedPlugin.logoUrl}
|
||||
alt={`${selectedPlugin.name} logo`}
|
||||
src={selectedAutomation.logoUrl}
|
||||
alt={`${selectedAutomation.name} logo`}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
@@ -439,9 +439,9 @@ const PluginMarketplace: React.FC = () => {
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.name}
|
||||
{selectedAutomation.name}
|
||||
</h3>
|
||||
{selectedPlugin.isVerified && (
|
||||
{selectedAutomation.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||
)}
|
||||
</div>
|
||||
@@ -449,7 +449,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setShowCode(false);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@@ -467,11 +467,11 @@ const PluginMarketplace: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
|
||||
{categoryIcons[selectedPlugin.category]}
|
||||
{selectedPlugin.category}
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedAutomation.category]}`}>
|
||||
{categoryIcons[selectedAutomation.category]}
|
||||
{selectedAutomation.category}
|
||||
</span>
|
||||
{selectedPlugin.isFeatured && (
|
||||
{selectedAutomation.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-4 w-4" />
|
||||
Featured
|
||||
@@ -481,25 +481,25 @@ const PluginMarketplace: React.FC = () => {
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.description', 'Description')}
|
||||
{t('automations.description', 'Description')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.description}
|
||||
{selectedAutomation.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.version', 'Version')}
|
||||
{t('automations.version', 'Version')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedAutomation.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.author', 'Author')}
|
||||
{t('automations.author', 'Author')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedAutomation.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -507,29 +507,29 @@ const PluginMarketplace: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.rating.toFixed(1)}
|
||||
{selectedAutomation.rating.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
|
||||
({selectedAutomation.ratingCount} {t('automations.ratings', 'ratings')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
|
||||
{selectedAutomation.installCount.toLocaleString()} {t('automations.installs', 'installs')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Viewer Section */}
|
||||
{selectedPlugin.pluginCode && (
|
||||
{selectedAutomation.automationCode && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.viewCode', 'View Code')}
|
||||
{t('automations.viewCode', 'View Code')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-gray-600 dark:text-gray-400 transition-transform ${showCode ? 'rotate-180' : ''}`}
|
||||
@@ -540,14 +540,14 @@ const PluginMarketplace: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
{/* Platform-only code warnings */}
|
||||
{(() => {
|
||||
const { hasPlatformCode, warnings } = detectPlatformOnlyCode(selectedPlugin.pluginCode || '');
|
||||
const { hasPlatformCode, warnings } = detectPlatformOnlyCode(selectedAutomation.automationCode || '');
|
||||
return hasPlatformCode ? (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-amber-900 dark:text-amber-200 mb-2">
|
||||
{t('plugins.platformOnlyWarning', 'Platform-Only Features Detected')}
|
||||
{t('automations.platformOnlyWarning', 'Platform-Only Features Detected')}
|
||||
</h5>
|
||||
<ul className="space-y-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
{warnings.map((warning, idx) => (
|
||||
@@ -576,7 +576,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
}}
|
||||
showLineNumbers
|
||||
>
|
||||
{selectedPlugin.pluginCode}
|
||||
{selectedAutomation.automationCode}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,25 +592,25 @@ const PluginMarketplace: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setShowCode(false);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{installedTemplateIds.has(selectedPlugin.id) ? t('common.close', 'Close') : t('common.cancel', 'Cancel')}
|
||||
{installedTemplateIds.has(selectedAutomation.id) ? t('common.close', 'Close') : t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
{installedTemplateIds.has(selectedPlugin.id) ? (
|
||||
{installedTemplateIds.has(selectedAutomation.id) ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setShowCode(false);
|
||||
navigate('/dashboard/plugins/my-plugins');
|
||||
navigate('/dashboard/automations/my-automations');
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{t('plugins.viewInstalled', 'View in My Plugins')}
|
||||
{t('automations.viewInstalled', 'View in My Automations')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -621,12 +621,12 @@ const PluginMarketplace: React.FC = () => {
|
||||
{installMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('plugins.installing', 'Installing...')}
|
||||
{t('automations.installing', 'Installing...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
{t('plugins.install', 'Install')}
|
||||
{t('automations.install', 'Install')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -637,7 +637,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* What's Next Modal - shown after successful install */}
|
||||
{showWhatsNextModal && selectedPlugin && (
|
||||
{showWhatsNextModal && selectedAutomation && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full shadow-2xl overflow-hidden">
|
||||
{/* Success Header */}
|
||||
@@ -646,10 +646,10 @@ const PluginMarketplace: React.FC = () => {
|
||||
<CheckCircle className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
Plugin Installed!
|
||||
Automation Installed!
|
||||
</h2>
|
||||
<p className="text-green-100">
|
||||
{selectedPlugin.name} is ready to use
|
||||
{selectedAutomation.name} is ready to use
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -663,7 +663,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowWhatsNextModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
navigate('/dashboard/tasks');
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-blue-500 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors text-left group"
|
||||
@@ -686,8 +686,8 @@ const PluginMarketplace: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowWhatsNextModal(false);
|
||||
setSelectedPlugin(null);
|
||||
navigate('/dashboard/plugins/my-plugins');
|
||||
setSelectedAutomation(null);
|
||||
navigate('/dashboard/automations/my-automations');
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors text-left group"
|
||||
>
|
||||
@@ -699,7 +699,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
Configure settings
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Set up plugin options and customize behavior
|
||||
Set up automation options and customize behavior
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-purple-600 dark:group-hover:text-purple-400 transition-colors" />
|
||||
@@ -709,7 +709,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowWhatsNextModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
}}
|
||||
className="w-full p-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors text-center"
|
||||
>
|
||||
@@ -724,4 +724,4 @@ const PluginMarketplace: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginMarketplace;
|
||||
export default AutomationMarketplace;
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Create Plugin Page
|
||||
* Create Automation Page
|
||||
*
|
||||
* Allows businesses to create custom plugins with code editor,
|
||||
* Allows businesses to create custom automations with code editor,
|
||||
* category selection, and visibility options.
|
||||
*/
|
||||
|
||||
@@ -31,11 +31,11 @@ import {
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import api from '../api/client';
|
||||
import { PluginCategory } from '../types';
|
||||
import { AutomationCategory } from '../types';
|
||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||
|
||||
// Category icon mapping
|
||||
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
const categoryIcons: Record<AutomationCategory, React.ReactNode> = {
|
||||
EMAIL: <Mail className="h-4 w-4" />,
|
||||
REPORTS: <BarChart3 className="h-4 w-4" />,
|
||||
CUSTOMER: <Users className="h-4 w-4" />,
|
||||
@@ -46,21 +46,21 @@ const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
};
|
||||
|
||||
// Category descriptions
|
||||
const categoryDescriptions: Record<PluginCategory, string> = {
|
||||
const categoryDescriptions: Record<AutomationCategory, string> = {
|
||||
EMAIL: 'Email notifications and automated messaging',
|
||||
REPORTS: 'Analytics, reports, and data exports',
|
||||
CUSTOMER: 'Customer engagement and retention',
|
||||
BOOKING: 'Scheduling and booking automation',
|
||||
INTEGRATION: 'Third-party service integrations',
|
||||
AUTOMATION: 'General business automation',
|
||||
OTHER: 'Miscellaneous plugins',
|
||||
OTHER: 'Miscellaneous automations',
|
||||
};
|
||||
|
||||
// Default plugin code template
|
||||
const DEFAULT_PLUGIN_CODE = `# My Custom Plugin
|
||||
// Default automation code template
|
||||
const DEFAULT_AUTOMATION_CODE = `# My Custom Automation
|
||||
#
|
||||
# This plugin runs on a schedule and can interact with your business data.
|
||||
# Use template variables to make your plugin configurable.
|
||||
# This automation runs on a schedule and can interact with your business data.
|
||||
# Use template variables to make your automation configurable.
|
||||
#
|
||||
# Available template variables:
|
||||
# {{PROMPT:variable_name:default_value:description}}
|
||||
@@ -95,14 +95,14 @@ interface FormData {
|
||||
name: string;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
category: PluginCategory;
|
||||
pluginCode: string;
|
||||
category: AutomationCategory;
|
||||
automationCode: string;
|
||||
version: string;
|
||||
logoUrl: string;
|
||||
visibility: 'PRIVATE' | 'PUBLIC';
|
||||
}
|
||||
|
||||
const CreatePlugin: React.FC = () => {
|
||||
const CreateAutomation: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -113,7 +113,7 @@ const CreatePlugin: React.FC = () => {
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
category: 'AUTOMATION',
|
||||
pluginCode: DEFAULT_PLUGIN_CODE,
|
||||
automationCode: DEFAULT_AUTOMATION_CODE,
|
||||
version: '1.0.0',
|
||||
logoUrl: '',
|
||||
visibility: 'PRIVATE',
|
||||
@@ -160,11 +160,11 @@ const CreatePlugin: React.FC = () => {
|
||||
// Update extracted variables when code changes
|
||||
const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newCode = e.target.value;
|
||||
setFormData(prev => ({ ...prev, pluginCode: newCode }));
|
||||
setFormData(prev => ({ ...prev, automationCode: newCode }));
|
||||
setExtractedVariables(extractVariables(newCode));
|
||||
};
|
||||
|
||||
// Create plugin mutation
|
||||
// Create automation mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: FormData) => {
|
||||
const payload = {
|
||||
@@ -172,17 +172,17 @@ const CreatePlugin: React.FC = () => {
|
||||
short_description: data.shortDescription,
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
plugin_code: data.pluginCode,
|
||||
automation_code: data.automationCode,
|
||||
version: data.version,
|
||||
logo_url: data.logoUrl || undefined,
|
||||
visibility: data.visibility,
|
||||
};
|
||||
const response = await api.post('/plugin-templates/', payload);
|
||||
const response = await api.post('/automation-templates/', payload);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-templates'] });
|
||||
navigate('/dashboard/plugins/my-plugins');
|
||||
queryClient.invalidateQueries({ queryKey: ['automation-templates'] });
|
||||
navigate('/dashboard/automations/my-automations');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -191,25 +191,25 @@ const CreatePlugin: React.FC = () => {
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
// Check if user can create plugins
|
||||
const canCreatePlugins = canUse('can_create_plugins');
|
||||
// Check if user can create automations
|
||||
const canCreateAutomations = canUse('can_create_automations');
|
||||
|
||||
if (!canCreatePlugins) {
|
||||
if (!canCreateAutomations) {
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-8 text-center">
|
||||
<AlertTriangle className="h-16 w-16 mx-auto text-amber-500 mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.upgradeRequired', 'Upgrade Required')}
|
||||
{t('automations.upgradeRequired', 'Upgrade Required')}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{t('plugins.upgradeToCreate', 'Plugin creation is available on higher-tier plans. Upgrade your subscription to create custom plugins.')}
|
||||
{t('automations.upgradeToCreate', 'Automation creation is available on higher-tier plans. Upgrade your subscription to create custom automations.')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/settings/billing')}
|
||||
className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
{t('plugins.viewPlans', 'View Plans')}
|
||||
{t('automations.viewPlans', 'View Plans')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,7 +221,7 @@ const CreatePlugin: React.FC = () => {
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/plugins/my-plugins')}
|
||||
onClick={() => navigate('/dashboard/automations/my-automations')}
|
||||
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
@@ -231,19 +231,19 @@ const CreatePlugin: React.FC = () => {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Code className="text-brand-500" />
|
||||
{t('plugins.createPlugin', 'Create Custom Plugin')}
|
||||
{t('automations.createAutomation', 'Create Custom Automation')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('plugins.createPluginDescription', 'Build a custom automation for your business')}
|
||||
{t('automations.createAutomationDescription', 'Build a custom automation for your business')}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/help/plugins"
|
||||
href="/help/automations"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
{t('plugins.viewDocs', 'View Documentation')}
|
||||
{t('automations.viewDocs', 'View Documentation')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,16 +252,16 @@ const CreatePlugin: React.FC = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Basic Info */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
{/* Plugin Name */}
|
||||
{/* Automation Name */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('plugins.basicInfo', 'Basic Information')}
|
||||
{t('automations.basicInfo', 'Basic Information')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('plugins.pluginName', 'Plugin Name')} *
|
||||
{t('automations.automationName', 'Automation Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -275,7 +275,7 @@ const CreatePlugin: React.FC = () => {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('plugins.shortDescription', 'Short Description')} *
|
||||
{t('automations.shortDescription', 'Short Description')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -291,24 +291,24 @@ const CreatePlugin: React.FC = () => {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('plugins.description', 'Full Description')}
|
||||
{t('automations.description', 'Full Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 resize-none"
|
||||
placeholder="Detailed description of what this plugin does..."
|
||||
placeholder="Detailed description of what this automation does..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('plugins.category', 'Category')} *
|
||||
{t('automations.category', 'Category')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as PluginCategory }))}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as AutomationCategory }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{Object.entries(categoryDescriptions).map(([key, desc]) => (
|
||||
@@ -321,7 +321,7 @@ const CreatePlugin: React.FC = () => {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('plugins.version', 'Version')}
|
||||
{t('automations.version', 'Version')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -335,7 +335,7 @@ const CreatePlugin: React.FC = () => {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<Image size={16} className="inline mr-1" />
|
||||
{t('plugins.logoUrl', 'Logo URL')} ({t('common.optional', 'optional')})
|
||||
{t('automations.logoUrl', 'Logo URL')} ({t('common.optional', 'optional')})
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -351,7 +351,7 @@ const CreatePlugin: React.FC = () => {
|
||||
{/* Visibility */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('plugins.visibility', 'Visibility')}
|
||||
{t('automations.visibility', 'Visibility')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -368,11 +368,11 @@ const CreatePlugin: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<EyeOff size={16} className="text-gray-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{t('plugins.private', 'Private')}
|
||||
{t('automations.private', 'Private')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.privateDescription', 'Only you can see and use this plugin')}
|
||||
{t('automations.privateDescription', 'Only you can see and use this automation')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -390,11 +390,11 @@ const CreatePlugin: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye size={16} className="text-green-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{t('plugins.public', 'Public (Marketplace)')}
|
||||
{t('automations.public', 'Public (Marketplace)')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.publicDescription', 'Submit for review to be listed in the marketplace')}
|
||||
{t('automations.publicDescription', 'Submit for review to be listed in the marketplace')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -405,7 +405,7 @@ const CreatePlugin: React.FC = () => {
|
||||
<div className="flex items-start gap-2">
|
||||
<Info size={16} className="text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{t('plugins.publicNote', 'Public plugins require approval before appearing in the marketplace. Our team will review your code for security and quality.')}
|
||||
{t('automations.publicNote', 'Public automations require approval before appearing in the marketplace. Our team will review your code for security and quality.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,7 +416,7 @@ const CreatePlugin: React.FC = () => {
|
||||
{extractedVariables.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('plugins.templateVariables', 'Detected Variables')}
|
||||
{t('automations.templateVariables', 'Detected Variables')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{extractedVariables.map((v, idx) => (
|
||||
@@ -451,7 +451,7 @@ const CreatePlugin: React.FC = () => {
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Code size={20} className="text-brand-500" />
|
||||
{t('plugins.pluginCode', 'Plugin Code')}
|
||||
{t('automations.automationCode', 'Automation Code')}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
@@ -459,7 +459,7 @@ const CreatePlugin: React.FC = () => {
|
||||
className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
{showPreview ? t('plugins.hidePreview', 'Hide Preview') : t('plugins.showPreview', 'Show Preview')}
|
||||
{showPreview ? t('automations.hidePreview', 'Hide Preview') : t('automations.showPreview', 'Show Preview')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -475,16 +475,16 @@ const CreatePlugin: React.FC = () => {
|
||||
}}
|
||||
showLineNumbers
|
||||
>
|
||||
{formData.pluginCode}
|
||||
{formData.automationCode}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={formData.pluginCode}
|
||||
value={formData.automationCode}
|
||||
onChange={handleCodeChange}
|
||||
rows={25}
|
||||
className="w-full px-4 py-4 bg-[#1e1e1e] text-gray-100 font-mono text-sm focus:outline-none resize-none"
|
||||
placeholder="# Write your plugin code here..."
|
||||
placeholder="# Write your automation code here..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
@@ -493,7 +493,7 @@ const CreatePlugin: React.FC = () => {
|
||||
{/* Quick Reference */}
|
||||
<div className="bg-gradient-to-r from-brand-50 to-indigo-50 dark:from-brand-900/20 dark:to-indigo-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{t('plugins.quickReference', 'Quick Reference')}
|
||||
{t('automations.quickReference', 'Quick Reference')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
@@ -525,7 +525,7 @@ const CreatePlugin: React.FC = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="text-red-500" size={20} />
|
||||
<p className="text-red-800 dark:text-red-200">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create plugin'}
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create automation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,7 +535,7 @@ const CreatePlugin: React.FC = () => {
|
||||
<div className="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/dashboard/plugins/my-plugins')}
|
||||
onClick={() => navigate('/dashboard/automations/my-automations')}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
@@ -548,14 +548,14 @@ const CreatePlugin: React.FC = () => {
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('plugins.creating', 'Creating...')}
|
||||
{t('automations.creating', 'Creating...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
{formData.visibility === 'PUBLIC'
|
||||
? t('plugins.createAndSubmit', 'Create & Submit for Review')
|
||||
: t('plugins.createPlugin', 'Create Plugin')}
|
||||
? t('automations.createAndSubmit', 'Create & Submit for Review')
|
||||
: t('automations.createAutomation', 'Create Automation')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -565,4 +565,4 @@ const CreatePlugin: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePlugin;
|
||||
export default CreateAutomation;
|
||||
@@ -27,13 +27,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api/client';
|
||||
import { PluginInstallation, PluginCategory } from '../types';
|
||||
import { AutomationInstallation, AutomationCategory } from '../types';
|
||||
import EmailTemplateSelector from '../components/EmailTemplateSelector';
|
||||
import { usePlanFeatures } from '../hooks/usePlanFeatures';
|
||||
import { LockedSection } from '../components/UpgradePrompt';
|
||||
|
||||
// Category icon mapping
|
||||
const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
const categoryIcons: Record<AutomationCategory, React.ReactNode> = {
|
||||
EMAIL: <Mail className="h-4 w-4" />,
|
||||
REPORTS: <BarChart3 className="h-4 w-4" />,
|
||||
CUSTOMER: <Users className="h-4 w-4" />,
|
||||
@@ -44,7 +44,7 @@ const categoryIcons: Record<PluginCategory, React.ReactNode> = {
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<PluginCategory, string> = {
|
||||
const categoryColors: Record<AutomationCategory, string> = {
|
||||
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
REPORTS: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
CUSTOMER: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
@@ -54,11 +54,11 @@ const categoryColors: Record<PluginCategory, string> = {
|
||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
const MyPlugins: React.FC = () => {
|
||||
const MyAutomations: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
|
||||
const [selectedAutomation, setSelectedAutomation] = useState<AutomationInstallation | null>(null);
|
||||
const [showUninstallModal, setShowUninstallModal] = useState(false);
|
||||
const [showRatingModal, setShowRatingModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
@@ -68,124 +68,124 @@ const MyPlugins: React.FC = () => {
|
||||
|
||||
// Check plan permissions
|
||||
const { canUse, isLoading: permissionsLoading } = usePlanFeatures();
|
||||
const hasPluginsFeature = canUse('plugins');
|
||||
const canCreatePlugins = canUse('can_create_plugins');
|
||||
const isLocked = !hasPluginsFeature;
|
||||
const hasAutomationsFeature = canUse('automations');
|
||||
const canCreateAutomations = canUse('can_create_automations');
|
||||
const isLocked = !hasAutomationsFeature;
|
||||
|
||||
// Fetch installed plugins - only when user has the feature
|
||||
const { data: plugins = [], isLoading, error } = useQuery<PluginInstallation[]>({
|
||||
queryKey: ['plugin-installations'],
|
||||
// Fetch installed automations - only when user has the feature
|
||||
const { data: automations = [], isLoading, error } = useQuery<AutomationInstallation[]>({
|
||||
queryKey: ['automation-installations'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/plugin-installations/');
|
||||
return data.map((p: any) => ({
|
||||
id: String(p.id),
|
||||
template: String(p.template),
|
||||
templateName: p.template_name || p.templateName,
|
||||
templateDescription: p.template_description || p.templateDescription,
|
||||
category: p.category,
|
||||
version: p.version,
|
||||
authorName: p.author_name || p.authorName,
|
||||
logoUrl: p.logo_url || p.logoUrl,
|
||||
templateVariables: p.template_variables || p.templateVariables || {},
|
||||
configValues: p.config_values || p.configValues || {},
|
||||
isActive: p.is_active !== undefined ? p.is_active : p.isActive,
|
||||
installedAt: p.installed_at || p.installedAt,
|
||||
hasUpdate: p.has_update !== undefined ? p.has_update : p.hasUpdate || false,
|
||||
rating: p.rating,
|
||||
review: p.review,
|
||||
const { data } = await api.get('/automation-installations/');
|
||||
return data.map((a: any) => ({
|
||||
id: String(a.id),
|
||||
template: String(a.template),
|
||||
templateName: a.template_name || a.templateName,
|
||||
templateDescription: a.template_description || a.templateDescription,
|
||||
category: a.category,
|
||||
version: a.version,
|
||||
authorName: a.author_name || a.authorName,
|
||||
logoUrl: a.logo_url || a.logoUrl,
|
||||
templateVariables: a.template_variables || a.templateVariables || {},
|
||||
configValues: a.config_values || a.configValues || {},
|
||||
isActive: a.is_active !== undefined ? a.is_active : a.isActive,
|
||||
installedAt: a.installed_at || a.installedAt,
|
||||
hasUpdate: a.has_update !== undefined ? a.has_update : a.hasUpdate || false,
|
||||
rating: a.rating,
|
||||
review: a.review,
|
||||
}));
|
||||
},
|
||||
// Don't fetch if user doesn't have the plugins feature
|
||||
enabled: hasPluginsFeature && !permissionsLoading,
|
||||
// Don't fetch if user doesn't have the automations feature
|
||||
enabled: hasAutomationsFeature && !permissionsLoading,
|
||||
});
|
||||
|
||||
// Uninstall plugin mutation
|
||||
// Uninstall automation mutation
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: async (pluginId: string) => {
|
||||
await api.delete(`/plugin-installations/${pluginId}/`);
|
||||
mutationFn: async (automationId: string) => {
|
||||
await api.delete(`/automation-installations/${automationId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
|
||||
setShowUninstallModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Rate plugin mutation
|
||||
// Rate automation mutation
|
||||
const rateMutation = useMutation({
|
||||
mutationFn: async ({ pluginId, rating, review }: { pluginId: string; rating: number; review: string }) => {
|
||||
const { data } = await api.post(`/plugin-installations/${pluginId}/rate/`, {
|
||||
mutationFn: async ({ automationId, rating, review }: { automationId: string; rating: number; review: string }) => {
|
||||
const { data } = await api.post(`/automation-installations/${automationId}/rate/`, {
|
||||
rating,
|
||||
review,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
|
||||
setShowRatingModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setRating(0);
|
||||
setReview('');
|
||||
},
|
||||
});
|
||||
|
||||
// Update plugin mutation
|
||||
// Update automation mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (pluginId: string) => {
|
||||
const { data } = await api.post(`/plugin-installations/${pluginId}/update/`);
|
||||
mutationFn: async (automationId: string) => {
|
||||
const { data } = await api.post(`/automation-installations/${automationId}/update/`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Edit config mutation
|
||||
const editConfigMutation = useMutation({
|
||||
mutationFn: async ({ pluginId, configValues }: { pluginId: string; configValues: Record<string, any> }) => {
|
||||
const { data } = await api.patch(`/plugin-installations/${pluginId}/`, {
|
||||
mutationFn: async ({ automationId, configValues }: { automationId: string; configValues: Record<string, any> }) => {
|
||||
const { data } = await api.patch(`/automation-installations/${automationId}/`, {
|
||||
config_values: configValues,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['automation-installations'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setConfigValues({});
|
||||
},
|
||||
});
|
||||
|
||||
const handleUninstall = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
const handleUninstall = (automation: AutomationInstallation) => {
|
||||
setSelectedAutomation(automation);
|
||||
setShowUninstallModal(true);
|
||||
};
|
||||
|
||||
const confirmUninstall = () => {
|
||||
if (selectedPlugin) {
|
||||
uninstallMutation.mutate(selectedPlugin.id);
|
||||
if (selectedAutomation) {
|
||||
uninstallMutation.mutate(selectedAutomation.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRating = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
setRating(plugin.rating || 0);
|
||||
setReview(plugin.review || '');
|
||||
const handleRating = (automation: AutomationInstallation) => {
|
||||
setSelectedAutomation(automation);
|
||||
setRating(automation.rating || 0);
|
||||
setReview(automation.review || '');
|
||||
setShowRatingModal(true);
|
||||
};
|
||||
|
||||
const submitRating = () => {
|
||||
if (selectedPlugin && rating > 0) {
|
||||
if (selectedAutomation && rating > 0) {
|
||||
rateMutation.mutate({
|
||||
pluginId: selectedPlugin.id,
|
||||
automationId: selectedAutomation.id,
|
||||
rating,
|
||||
review,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = (plugin: PluginInstallation) => {
|
||||
updateMutation.mutate(plugin.id);
|
||||
const handleUpdate = (automation: AutomationInstallation) => {
|
||||
updateMutation.mutate(automation.id);
|
||||
};
|
||||
|
||||
const unescapeString = (str: string): string => {
|
||||
@@ -198,12 +198,12 @@ const MyPlugins: React.FC = () => {
|
||||
.replace(/\\\\/g, '\\');
|
||||
};
|
||||
|
||||
const handleEdit = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
const handleEdit = (automation: AutomationInstallation) => {
|
||||
setSelectedAutomation(automation);
|
||||
// Convert escape sequences to actual characters for display
|
||||
const displayValues = { ...plugin.configValues || {} };
|
||||
if (plugin.templateVariables) {
|
||||
Object.entries(plugin.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
const displayValues = { ...automation.configValues || {} };
|
||||
if (automation.templateVariables) {
|
||||
Object.entries(automation.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
if (displayValues[key]) {
|
||||
displayValues[key] = unescapeString(displayValues[key]);
|
||||
}
|
||||
@@ -224,18 +224,18 @@ const MyPlugins: React.FC = () => {
|
||||
};
|
||||
|
||||
const submitConfigEdit = () => {
|
||||
if (selectedPlugin) {
|
||||
if (selectedAutomation) {
|
||||
// Convert actual characters back to escape sequences for storage
|
||||
const storageValues = { ...configValues };
|
||||
if (selectedPlugin.templateVariables) {
|
||||
Object.entries(selectedPlugin.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
if (selectedAutomation.templateVariables) {
|
||||
Object.entries(selectedAutomation.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
if (storageValues[key]) {
|
||||
storageValues[key] = escapeString(storageValues[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
editConfigMutation.mutate({
|
||||
pluginId: selectedPlugin.id,
|
||||
automationId: selectedAutomation.id,
|
||||
configValues: storageValues,
|
||||
});
|
||||
}
|
||||
@@ -270,59 +270,59 @@ const MyPlugins: React.FC = () => {
|
||||
const effectivelyLocked = isLocked || is403Error;
|
||||
|
||||
return (
|
||||
<LockedSection feature="plugins" isLocked={effectivelyLocked} variant="overlay">
|
||||
<LockedSection feature="automations" isLocked={effectivelyLocked} variant="overlay">
|
||||
<div className="p-8 space-y-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Package className="h-7 w-7 text-brand-600" />
|
||||
{t('plugins.myPlugins', 'My Plugins')}
|
||||
{t('automations.myAutomations', 'My Automations')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('plugins.myPluginsDescription', 'Manage your installed plugins')}
|
||||
{t('automations.myAutomationsDescription', 'Manage your installed automations')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/plugins/marketplace')}
|
||||
onClick={() => navigate('/dashboard/automations/marketplace')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
{t('plugins.browseMarketplace', 'Browse Marketplace')}
|
||||
{t('automations.browseMarketplace', 'Browse Marketplace')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Plugin List */}
|
||||
{plugins.length === 0 ? (
|
||||
{/* Automation List */}
|
||||
{automations.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<Package className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('plugins.noPluginsInstalled', 'No plugins installed yet')}
|
||||
{t('automations.noAutomationsInstalled', 'No automations installed yet')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/plugins/marketplace')}
|
||||
onClick={() => navigate('/dashboard/automations/marketplace')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('plugins.browseMarketplace', 'Browse Marketplace')}
|
||||
{t('automations.browseMarketplace', 'Browse Marketplace')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{plugins.map((plugin) => (
|
||||
{automations.map((item) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
key={item.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Plugin Info */}
|
||||
{/* Automation Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{plugin.logoUrl && (
|
||||
{item.logoUrl && (
|
||||
<img
|
||||
src={plugin.logoUrl}
|
||||
alt={`${plugin.templateName} logo`}
|
||||
src={item.logoUrl}
|
||||
alt={`${item.templateName} logo`}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
@@ -331,13 +331,13 @@ const MyPlugins: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{plugin.templateName}
|
||||
{item.templateName}
|
||||
</h3>
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
|
||||
{categoryIcons[plugin.category]}
|
||||
{plugin.category}
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[item.category]}`}>
|
||||
{categoryIcons[item.category]}
|
||||
{item.category}
|
||||
</span>
|
||||
{plugin.hasUpdate && (
|
||||
{item.hasUpdate && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Update Available
|
||||
@@ -346,53 +346,53 @@ const MyPlugins: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{plugin.templateDescription}
|
||||
{item.templateDescription}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{plugin.authorName && (
|
||||
{item.authorName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('plugins.author', 'Author')}:
|
||||
{t('automations.author', 'Author')}:
|
||||
</span>
|
||||
<span>{plugin.authorName}</span>
|
||||
<span>{item.authorName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('plugins.version', 'Version')}:
|
||||
{t('automations.version', 'Version')}:
|
||||
</span>
|
||||
<span>{plugin.version}</span>
|
||||
<span>{item.version}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('plugins.installedOn', 'Installed on')}:
|
||||
{t('automations.installedOn', 'Installed on')}:
|
||||
</span>
|
||||
<span>
|
||||
{new Date(plugin.installedAt).toLocaleDateString()}
|
||||
{new Date(item.installedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{plugin.isActive ? (
|
||||
{item.isActive ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||
{t('plugins.active', 'Active')}
|
||||
{t('automations.active', 'Active')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.inactive', 'Inactive')}
|
||||
{t('automations.inactive', 'Inactive')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{plugin.rating && (
|
||||
{item.rating && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-amber-500 fill-amber-500" />
|
||||
<span className="font-medium">{plugin.rating}/5</span>
|
||||
<span className="font-medium">{item.rating}/5</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -402,58 +402,58 @@ const MyPlugins: React.FC = () => {
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{/* Configure button */}
|
||||
<button
|
||||
onClick={() => handleEdit(plugin)}
|
||||
onClick={() => handleEdit(item)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||
title={t('plugins.configure', 'Configure')}
|
||||
title={t('automations.configure', 'Configure')}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
{t('plugins.configure', 'Configure')}
|
||||
{t('automations.configure', 'Configure')}
|
||||
</button>
|
||||
{/* Schedule button - only if not already scheduled */}
|
||||
{!plugin.scheduledTaskId && (
|
||||
{!item.scheduledTaskId && (
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tasks')}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
title={t('plugins.schedule', 'Schedule')}
|
||||
title={t('automations.schedule', 'Schedule')}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t('plugins.schedule', 'Schedule')}
|
||||
{t('automations.schedule', 'Schedule')}
|
||||
</button>
|
||||
)}
|
||||
{/* Already scheduled indicator */}
|
||||
{plugin.scheduledTaskId && (
|
||||
{item.scheduledTaskId && (
|
||||
<span className="flex items-center gap-1.5 px-3 py-2 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg text-sm font-medium">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t('plugins.scheduled', 'Scheduled')}
|
||||
{t('automations.scheduled', 'Scheduled')}
|
||||
</span>
|
||||
)}
|
||||
{plugin.hasUpdate && (
|
||||
{item.hasUpdate && (
|
||||
<button
|
||||
onClick={() => handleUpdate(plugin)}
|
||||
onClick={() => handleUpdate(item)}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||
title={t('plugins.update', 'Update')}
|
||||
title={t('automations.update', 'Update')}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{t('plugins.update', 'Update')}
|
||||
{t('automations.update', 'Update')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRating(plugin)}
|
||||
onClick={() => handleRating(item)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium"
|
||||
title={plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
title={item.rating ? t('automations.editRating', 'Edit Rating') : t('automations.rate', 'Rate')}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${plugin.rating ? 'fill-amber-500 text-amber-500' : ''}`} />
|
||||
{plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
<Star className={`h-4 w-4 ${item.rating ? 'fill-amber-500 text-amber-500' : ''}`} />
|
||||
{item.rating ? t('automations.editRating', 'Edit Rating') : t('automations.rate', 'Rate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUninstall(plugin)}
|
||||
onClick={() => handleUninstall(item)}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title={t('plugins.uninstall', 'Uninstall')}
|
||||
title={t('automations.uninstall', 'Uninstall')}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -467,17 +467,17 @@ const MyPlugins: React.FC = () => {
|
||||
|
||||
{/* Info Box */}
|
||||
<div className={`rounded-xl p-6 border ${
|
||||
canCreatePlugins
|
||||
canCreateAutomations
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`shrink-0 p-2 rounded-lg ${
|
||||
canCreatePlugins
|
||||
canCreateAutomations
|
||||
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||
: 'bg-gradient-to-br from-amber-400 to-orange-500'
|
||||
}`}>
|
||||
{canCreatePlugins ? (
|
||||
{canCreateAutomations ? (
|
||||
<Package className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Crown className="h-6 w-6 text-white" />
|
||||
@@ -486,13 +486,13 @@ const MyPlugins: React.FC = () => {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className={`text-lg font-semibold ${
|
||||
canCreatePlugins
|
||||
canCreateAutomations
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{t('plugins.needCustomPlugin', 'Need a custom plugin?')}
|
||||
{t('automations.needCustomAutomation', 'Need a custom automation?')}
|
||||
</h3>
|
||||
{!canCreatePlugins && (
|
||||
{!canCreateAutomations && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t('common.upgradeRequired', 'Upgrade Required')}
|
||||
@@ -500,22 +500,22 @@ const MyPlugins: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<p className={`mb-4 ${
|
||||
canCreatePlugins
|
||||
canCreateAutomations
|
||||
? 'text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{canCreatePlugins
|
||||
? t('plugins.customPluginDescription', 'Create your own custom plugins to extend your business functionality with specific features tailored to your needs.')
|
||||
: t('plugins.customPluginUpgradeDescription', 'Custom plugins allow you to create automated workflows tailored to your business needs. Upgrade your plan to unlock this feature.')
|
||||
{canCreateAutomations
|
||||
? t('automations.customAutomationDescription', 'Create your own custom automations to extend your business functionality with specific features tailored to your needs.')
|
||||
: t('automations.customAutomationUpgradeDescription', 'Custom automations allow you to create automated workflows tailored to your business needs. Upgrade your plan to unlock this feature.')
|
||||
}
|
||||
</p>
|
||||
{canCreatePlugins ? (
|
||||
{canCreateAutomations ? (
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/plugins/create')}
|
||||
onClick={() => navigate('/dashboard/automations/create')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('plugins.createCustomPlugin', 'Create Custom Plugin')}
|
||||
{t('automations.createCustomAutomation', 'Create Custom Automation')}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
@@ -532,7 +532,7 @@ const MyPlugins: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Uninstall Confirmation Modal */}
|
||||
{showUninstallModal && selectedPlugin && (
|
||||
{showUninstallModal && selectedAutomation && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
@@ -542,13 +542,13 @@ const MyPlugins: React.FC = () => {
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.confirmUninstall', 'Confirm Uninstall')}
|
||||
{t('automations.confirmUninstall', 'Confirm Uninstall')}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUninstallModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
@@ -559,10 +559,10 @@ const MyPlugins: React.FC = () => {
|
||||
{/* Modal Body */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t('plugins.uninstallWarning', 'Are you sure you want to uninstall')} <span className="font-semibold text-gray-900 dark:text-white">{selectedPlugin.templateName}</span>?
|
||||
{t('automations.uninstallWarning', 'Are you sure you want to uninstall')} <span className="font-semibold text-gray-900 dark:text-white">{selectedAutomation.templateName}</span>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.uninstallNote', 'This action cannot be undone. Your plugin data and settings will be removed.')}
|
||||
{t('automations.uninstallNote', 'This action cannot be undone. Your automation data and settings will be removed.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -571,7 +571,7 @@ const MyPlugins: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUninstallModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
@@ -585,12 +585,12 @@ const MyPlugins: React.FC = () => {
|
||||
{uninstallMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('plugins.uninstalling', 'Uninstalling...')}
|
||||
{t('automations.uninstalling', 'Uninstalling...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t('plugins.uninstall', 'Uninstall')}
|
||||
{t('automations.uninstall', 'Uninstall')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -600,18 +600,18 @@ const MyPlugins: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Rating Modal */}
|
||||
{showRatingModal && selectedPlugin && (
|
||||
{showRatingModal && selectedAutomation && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.ratePlugin', 'Rate Plugin')}
|
||||
{t('automations.rateAutomation', 'Rate Automation')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRatingModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setRating(0);
|
||||
setReview('');
|
||||
}}
|
||||
@@ -625,17 +625,17 @@ const MyPlugins: React.FC = () => {
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
{selectedPlugin.templateName}
|
||||
{selectedAutomation.templateName}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.rateDescription', 'Share your experience with this plugin')}
|
||||
{t('automations.rateDescription', 'Share your experience with this automation')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Star Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('plugins.yourRating', 'Your Rating')} *
|
||||
{t('automations.yourRating', 'Your Rating')} *
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
@@ -660,14 +660,14 @@ const MyPlugins: React.FC = () => {
|
||||
{/* Review */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('plugins.review', 'Review')} ({t('plugins.optional', 'optional')})
|
||||
{t('automations.review', 'Review')} ({t('automations.optional', 'optional')})
|
||||
</label>
|
||||
<textarea
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
|
||||
placeholder={t('plugins.reviewPlaceholder', 'Share your thoughts about this plugin...')}
|
||||
placeholder={t('automations.reviewPlaceholder', 'Share your thoughts about this automation...')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -677,7 +677,7 @@ const MyPlugins: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRatingModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setRating(0);
|
||||
setReview('');
|
||||
}}
|
||||
@@ -693,12 +693,12 @@ const MyPlugins: React.FC = () => {
|
||||
{rateMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('plugins.submitting', 'Submitting...')}
|
||||
{t('automations.submitting', 'Submitting...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Star className="h-4 w-4" />
|
||||
{t('plugins.submitRating', 'Submit Rating')}
|
||||
{t('automations.submitRating', 'Submit Rating')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -708,18 +708,18 @@ const MyPlugins: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Edit Config Modal */}
|
||||
{showEditModal && selectedPlugin && (
|
||||
{showEditModal && selectedAutomation && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full overflow-hidden max-h-[80vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.editConfig', 'Edit Plugin Configuration')}
|
||||
{t('automations.editConfig', 'Edit Automation Configuration')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setConfigValues({});
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@@ -732,17 +732,17 @@ const MyPlugins: React.FC = () => {
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
{selectedPlugin.templateName}
|
||||
{selectedAutomation.templateName}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedPlugin.templateDescription}
|
||||
{selectedAutomation.templateDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Config Fields */}
|
||||
{selectedPlugin.templateVariables && Object.keys(selectedPlugin.templateVariables).length > 0 ? (
|
||||
{selectedAutomation.templateVariables && Object.keys(selectedAutomation.templateVariables).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(selectedPlugin.templateVariables).map(([key, variable]: [string, any]) => (
|
||||
{Object.entries(selectedAutomation.templateVariables).map(([key, variable]: [string, any]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{variable.description || key}
|
||||
@@ -783,7 +783,7 @@ const MyPlugins: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.noConfigOptions', 'This plugin has no configuration options')}
|
||||
{t('automations.noConfigOptions', 'This automation has no configuration options')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -793,7 +793,7 @@ const MyPlugins: React.FC = () => {
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setSelectedAutomation(null);
|
||||
setConfigValues({});
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
@@ -808,7 +808,7 @@ const MyPlugins: React.FC = () => {
|
||||
{editConfigMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('plugins.saving', 'Saving...')}
|
||||
{t('automations.saving', 'Saving...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -826,4 +826,4 @@ const MyPlugins: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MyPlugins;
|
||||
export default MyAutomations;
|
||||
@@ -270,9 +270,10 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
|
||||
const hasActiveFilters = filterStatuses.size < DEFAULT_STATUSES.length || filterResources.size > 0 || filterServices.size > 0;
|
||||
|
||||
// Scroll to current time on mount (centered in view)
|
||||
// Scroll to current time on mount and when view mode changes (centered in view)
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
if (viewMode === 'month') return; // Month view doesn't have horizontal scrolling
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(viewDate);
|
||||
@@ -282,20 +283,39 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
|
||||
// Only scroll if today is in the current view
|
||||
if (viewMode === 'day' && nowDay.getTime() !== today.getTime()) return;
|
||||
if (viewMode === 'week') {
|
||||
// Check if today falls within the current week view (week starts on Sunday)
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay()); // Sunday
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6); // Saturday
|
||||
if (nowDay < weekStart || nowDay > weekEnd) return;
|
||||
}
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const containerWidth = container.clientWidth;
|
||||
|
||||
// Calculate day width (same formula as used elsewhere in component)
|
||||
const calculatedDayWidth = (END_HOUR - START_HOUR) * 60 * (PIXELS_PER_MINUTE * zoomLevel);
|
||||
|
||||
// Calculate current time offset in pixels
|
||||
const startOfDay = new Date(now);
|
||||
startOfDay.setHours(START_HOUR, 0, 0, 0);
|
||||
const minutesSinceStart = (now.getTime() - startOfDay.getTime()) / (1000 * 60);
|
||||
const currentTimeOffset = minutesSinceStart * PIXELS_PER_MINUTE * zoomLevel;
|
||||
|
||||
// For week view, add the day offset
|
||||
let dayOffset = 0;
|
||||
if (viewMode === 'week') {
|
||||
// Week starts on Sunday (day 0), so dayOfWeek directly maps to position
|
||||
const dayOfWeek = now.getDay();
|
||||
dayOffset = dayOfWeek * calculatedDayWidth;
|
||||
}
|
||||
|
||||
// Scroll so current time is centered
|
||||
const scrollPosition = currentTimeOffset - (containerWidth / 2);
|
||||
const scrollPosition = dayOffset + currentTimeOffset - (containerWidth / 2);
|
||||
container.scrollLeft = Math.max(0, scrollPosition);
|
||||
}, []);
|
||||
}, [viewMode, zoomLevel, viewDate]);
|
||||
|
||||
const addToHistory = (action: HistoryAction) => {
|
||||
// Remove any history after current index (when doing new action after undo)
|
||||
@@ -1905,7 +1925,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 px-2 py-2 text-xs font-medium text-gray-400 select-none"
|
||||
style={{ width: 60 * (PIXELS_PER_MINUTE * zoomLevel) }}
|
||||
>
|
||||
{hour > 12 ? `${hour - 12} PM` : `${hour} ${hour === 12 ? 'PM' : 'AM'}`}
|
||||
{hour === 0 || hour === 24 ? '12 AM' : hour === 12 ? '12 PM' : hour > 12 ? `${hour - 12} PM` : `${hour} AM`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -506,7 +506,7 @@ const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||
{t('staffDashboard.yourWeeklyOverview', 'Your Weekly Schedule')}
|
||||
</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={150}>
|
||||
<BarChart data={weeklyChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||
<XAxis
|
||||
|
||||
@@ -29,8 +29,8 @@ interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
plugin_display_name: string;
|
||||
automation_name: string;
|
||||
automation_display_name: string;
|
||||
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
|
||||
cron_expression?: string;
|
||||
interval_minutes?: number;
|
||||
@@ -39,12 +39,12 @@ interface ScheduledTask {
|
||||
last_run_at?: string;
|
||||
status: 'ACTIVE' | 'PAUSED' | 'DISABLED';
|
||||
last_run_status?: string;
|
||||
plugin_config: Record<string, any>;
|
||||
automation_config: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface PluginInstallation {
|
||||
interface AutomationInstallation {
|
||||
id: string;
|
||||
template: number;
|
||||
template_name: string;
|
||||
@@ -62,13 +62,13 @@ interface PluginInstallation {
|
||||
has_update: boolean;
|
||||
}
|
||||
|
||||
interface GlobalEventPlugin {
|
||||
interface GlobalEventAutomation {
|
||||
id: string;
|
||||
plugin_installation: number;
|
||||
plugin_name: string;
|
||||
plugin_description: string;
|
||||
plugin_category: string;
|
||||
plugin_logo_url?: string;
|
||||
automation_installation: number;
|
||||
automation_name: string;
|
||||
automation_description: string;
|
||||
automation_category: string;
|
||||
automation_logo_url?: string;
|
||||
trigger: string;
|
||||
trigger_display: string;
|
||||
offset_minutes: number;
|
||||
@@ -87,7 +87,7 @@ type UnifiedTask = {
|
||||
data: ScheduledTask;
|
||||
} | {
|
||||
type: 'event';
|
||||
data: GlobalEventPlugin;
|
||||
data: GlobalEventAutomation;
|
||||
};
|
||||
|
||||
const Tasks: React.FC = () => {
|
||||
@@ -95,13 +95,13 @@ const Tasks: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<ScheduledTask | null>(null);
|
||||
const [editingEventAutomation, setEditingEventAutomation] = useState<GlobalEventPlugin | null>(null);
|
||||
const [editingEventAutomation, setEditingEventAutomation] = useState<GlobalEventAutomation | null>(null);
|
||||
|
||||
// Check plan permissions - tasks requires both plugins AND tasks features
|
||||
// Check plan permissions - tasks requires both automations AND tasks features
|
||||
const { canUse, isLoading: permissionsLoading } = usePlanFeatures();
|
||||
const hasPluginsFeature = canUse('plugins');
|
||||
const hasAutomationsFeature = canUse('automations');
|
||||
const hasTasksFeature = canUse('tasks');
|
||||
const isLocked = !hasPluginsFeature || !hasTasksFeature;
|
||||
const isLocked = !hasAutomationsFeature || !hasTasksFeature;
|
||||
|
||||
// Fetch scheduled tasks - only when user has the feature
|
||||
const { data: scheduledTasks = [], isLoading: tasksLoading, error: tasksError } = useQuery<ScheduledTask[]>({
|
||||
@@ -111,18 +111,18 @@ const Tasks: React.FC = () => {
|
||||
return data;
|
||||
},
|
||||
// Don't fetch if user doesn't have the required features
|
||||
enabled: hasPluginsFeature && hasTasksFeature && !permissionsLoading,
|
||||
enabled: hasAutomationsFeature && hasTasksFeature && !permissionsLoading,
|
||||
});
|
||||
|
||||
// Fetch global event plugins (event automations) - only when user has the feature
|
||||
const { data: eventAutomations = [], isLoading: automationsLoading, error: automationsError } = useQuery<GlobalEventPlugin[]>({
|
||||
queryKey: ['global-event-plugins'],
|
||||
// Fetch global event automations - only when user has the feature
|
||||
const { data: eventAutomations = [], isLoading: automationsLoading, error: automationsError } = useQuery<GlobalEventAutomation[]>({
|
||||
queryKey: ['global-event-automations'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('/global-event-plugins/');
|
||||
const { data } = await axios.get('/global-event-automations/');
|
||||
return data;
|
||||
},
|
||||
// Don't fetch if user doesn't have the required features
|
||||
enabled: hasPluginsFeature && hasTasksFeature && !permissionsLoading,
|
||||
enabled: hasAutomationsFeature && hasTasksFeature && !permissionsLoading,
|
||||
});
|
||||
|
||||
// Check if any error is a 403 (plan restriction)
|
||||
@@ -187,10 +187,10 @@ const Tasks: React.FC = () => {
|
||||
// Delete event automation
|
||||
const deleteEventAutomationMutation = useMutation({
|
||||
mutationFn: async (automationId: string) => {
|
||||
await axios.delete(`/global-event-plugins/${automationId}/`);
|
||||
await axios.delete(`/global-event-automations/${automationId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
|
||||
toast.success('Event automation deleted');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -201,10 +201,10 @@ const Tasks: React.FC = () => {
|
||||
// Toggle event automation active status
|
||||
const toggleEventAutomationMutation = useMutation({
|
||||
mutationFn: async (automationId: string) => {
|
||||
await axios.post(`/global-event-plugins/${automationId}/toggle/`);
|
||||
await axios.post(`/global-event-automations/${automationId}/toggle/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
|
||||
toast.success('Automation updated');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -214,11 +214,11 @@ const Tasks: React.FC = () => {
|
||||
|
||||
// Update event automation
|
||||
const updateEventAutomationMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<GlobalEventPlugin> }) => {
|
||||
await axios.patch(`/global-event-plugins/${id}/`, data);
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<GlobalEventAutomation> }) => {
|
||||
await axios.patch(`/global-event-automations/${id}/`, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
|
||||
toast.success('Automation updated');
|
||||
setEditingEventAutomation(null);
|
||||
},
|
||||
@@ -281,7 +281,7 @@ const Tasks: React.FC = () => {
|
||||
{t('Tasks')}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Schedule and manage automated plugin executions
|
||||
Schedule and manage automated executions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -402,7 +402,7 @@ const Tasks: React.FC = () => {
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{task.plugin_display_name}</span>
|
||||
<span>{task.automation_display_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
@@ -494,7 +494,7 @@ const Tasks: React.FC = () => {
|
||||
Event Automation
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{automation.plugin_name}
|
||||
{automation.automation_name}
|
||||
</h3>
|
||||
{automation.is_active ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
@@ -503,9 +503,9 @@ const Tasks: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{automation.plugin_description && (
|
||||
{automation.automation_description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3">
|
||||
{automation.plugin_description}
|
||||
{automation.automation_description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -580,7 +580,7 @@ const Tasks: React.FC = () => {
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-plugins'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['global-event-automations'] });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -612,10 +612,10 @@ const Tasks: React.FC = () => {
|
||||
|
||||
// Inline Edit Event Automation Modal component
|
||||
interface EditEventAutomationModalProps {
|
||||
automation: GlobalEventPlugin;
|
||||
automation: GlobalEventAutomation;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: Partial<GlobalEventPlugin>) => void;
|
||||
onSave: (data: Partial<GlobalEventAutomation>) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
@@ -693,16 +693,16 @@ const EditEventAutomationModal: React.FC<EditEventAutomationModalProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Plugin info */}
|
||||
{/* Automation info */}
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200 font-medium">
|
||||
Plugin
|
||||
Automation
|
||||
</p>
|
||||
<p className="text-purple-900 dark:text-purple-100">
|
||||
{automation.plugin_name}
|
||||
{automation.automation_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import Hero from '../../components/marketing/Hero';
|
||||
import FeatureCard from '../../components/marketing/FeatureCard';
|
||||
import PluginShowcase from '../../components/marketing/PluginShowcase';
|
||||
import AutomationShowcase from '../../components/marketing/AutomationShowcase';
|
||||
import BenefitsSection from '../../components/marketing/BenefitsSection';
|
||||
import TestimonialCard from '../../components/marketing/TestimonialCard';
|
||||
import CTASection from '../../components/marketing/CTASection';
|
||||
@@ -119,8 +119,8 @@ const HomePage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plugin Showcase - NEW */}
|
||||
<PluginShowcase />
|
||||
{/* Automation Showcase */}
|
||||
<AutomationShowcase />
|
||||
|
||||
{/* Benefits Section (Replaces Stats) */}
|
||||
<BenefitsSection />
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Helper to safely get array translations
|
||||
const getItems = (value: unknown): string[] => {
|
||||
return Array.isArray(value) ? value : [];
|
||||
};
|
||||
|
||||
const PrivacyPolicyPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -35,7 +40,7 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
{t('marketing.privacyPolicy.section2.subsection1.intro')}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section2.subsection1.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section2.subsection1.items', { returnObjects: true })).map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -45,7 +50,7 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
{t('marketing.privacyPolicy.section2.subsection2.intro')}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section2.subsection2.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section2.subsection2.items', { returnObjects: true })).map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -55,7 +60,7 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
{t('marketing.privacyPolicy.section3.intro')}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section3.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section3.items', { returnObjects: true })).map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -64,14 +69,15 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">{t('marketing.privacyPolicy.section4.subsection1.title')}</h3>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section4.subsection1.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section4.subsection1.items', { returnObjects: true })).map((item, index) => (
|
||||
// Content from translation files (trusted source, not user input)
|
||||
<li key={index} dangerouslySetInnerHTML={{ __html: item }} />
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">{t('marketing.privacyPolicy.section4.subsection2.title')}</h3>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section4.subsection2.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section4.subsection2.items', { returnObjects: true })).map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -81,7 +87,7 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
{t('marketing.privacyPolicy.section5.intro')}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section5.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section5.items', { returnObjects: true })).map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -99,7 +105,8 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
{t('marketing.privacyPolicy.section7.intro')}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section7.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section7.items', { returnObjects: true })).map((item, index) => (
|
||||
// Content from translation files (trusted source, not user input)
|
||||
<li key={index} dangerouslySetInnerHTML={{ __html: item }} />
|
||||
))}
|
||||
</ul>
|
||||
@@ -112,7 +119,7 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
{t('marketing.privacyPolicy.section8.intro')}
|
||||
</p>
|
||||
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||
{(t('marketing.privacyPolicy.section8.items', { returnObjects: true }) as string[]).map((item, index) => (
|
||||
{getItems(t('marketing.privacyPolicy.section8.items', { returnObjects: true })).map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -31,8 +31,8 @@ vi.mock('../../../components/marketing/FeatureCard', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/marketing/PluginShowcase', () => ({
|
||||
default: () => <div data-testid="plugin-showcase">Plugin Showcase Component</div>,
|
||||
vi.mock('../../../components/marketing/AutomationShowcase', () => ({
|
||||
default: () => <div data-testid="automation-showcase">Automation Showcase Component</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/marketing/BenefitsSection', () => ({
|
||||
@@ -225,13 +225,13 @@ describe('HomePage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Showcase Section', () => {
|
||||
it('should render the plugin showcase section', () => {
|
||||
describe('Automation Showcase Section', () => {
|
||||
it('should render the automation showcase section', () => {
|
||||
renderHomePage();
|
||||
|
||||
const pluginShowcase = screen.getByTestId('plugin-showcase');
|
||||
expect(pluginShowcase).toBeInTheDocument();
|
||||
expect(pluginShowcase).toHaveTextContent('Plugin Showcase Component');
|
||||
const automationShowcase = screen.getByTestId('automation-showcase');
|
||||
expect(automationShowcase).toBeInTheDocument();
|
||||
expect(automationShowcase).toHaveTextContent('Automation Showcase Component');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,13 +341,13 @@ describe('HomePage', () => {
|
||||
renderHomePage();
|
||||
|
||||
const hero = screen.getByTestId('hero-section');
|
||||
const pluginShowcase = screen.getByTestId('plugin-showcase');
|
||||
const automationShowcase = screen.getByTestId('automation-showcase');
|
||||
const benefits = screen.getByTestId('benefits-section');
|
||||
const cta = screen.getByTestId('cta-section');
|
||||
|
||||
// All sections should be present
|
||||
expect(hero).toBeInTheDocument();
|
||||
expect(pluginShowcase).toBeInTheDocument();
|
||||
expect(automationShowcase).toBeInTheDocument();
|
||||
expect(benefits).toBeInTheDocument();
|
||||
expect(cta).toBeInTheDocument();
|
||||
});
|
||||
@@ -541,10 +541,10 @@ describe('HomePage', () => {
|
||||
expect(featureCards).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('should integrate PluginShowcase component', () => {
|
||||
it('should integrate AutomationShowcase component', () => {
|
||||
renderHomePage();
|
||||
|
||||
expect(screen.getByTestId('plugin-showcase')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('automation-showcase')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should integrate BenefitsSection component', () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ const PlatformDashboard: React.FC = () => {
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-6">{t('platform.mrrGrowth')}</h3>
|
||||
<div className="h-80 min-h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={200} minHeight={200}>
|
||||
<AreaChart data={data}>
|
||||
<defs>
|
||||
<linearGradient id="colorMrr" x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -438,19 +438,19 @@ export interface BusinessOAuthCredentialsResponse {
|
||||
useCustomCredentials: boolean;
|
||||
}
|
||||
|
||||
// --- Plugin Types ---
|
||||
// --- Automation Types ---
|
||||
|
||||
export type PluginCategory = 'EMAIL' | 'REPORTS' | 'CUSTOMER' | 'BOOKING' | 'INTEGRATION' | 'AUTOMATION' | 'OTHER';
|
||||
export type AutomationCategory = 'EMAIL' | 'REPORTS' | 'CUSTOMER' | 'BOOKING' | 'INTEGRATION' | 'AUTOMATION' | 'OTHER';
|
||||
|
||||
export interface PluginTemplate {
|
||||
export interface AutomationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: PluginCategory;
|
||||
category: AutomationCategory;
|
||||
version: string;
|
||||
author: string;
|
||||
logoUrl?: string;
|
||||
pluginCode?: string;
|
||||
automationCode?: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
installCount: number;
|
||||
@@ -460,12 +460,12 @@ export interface PluginTemplate {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PluginInstallation {
|
||||
export interface AutomationInstallation {
|
||||
id: string;
|
||||
template: string;
|
||||
templateName: string;
|
||||
templateDescription: string;
|
||||
category: PluginCategory;
|
||||
category: AutomationCategory;
|
||||
version: string;
|
||||
authorName?: string;
|
||||
logoUrl?: string;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"6f1a4b04e7ad1ff99f24-d68c404526a42bfecb67",
|
||||
"6f1a4b04e7ad1ff99f24-6b3beabbc695cf50d356"
|
||||
]
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB |
232
frontend/tests/e2e/site-crawler.spec.ts
Normal file
232
frontend/tests/e2e/site-crawler.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Site Crawler Tests
|
||||
*
|
||||
* Crawls the site looking for broken links, console errors, and network failures.
|
||||
* Run with: npx playwright test site-crawler.spec.ts
|
||||
*
|
||||
* These tests are designed to be run in CI to catch issues before deployment.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
SiteCrawler,
|
||||
formatReport,
|
||||
loginAsUser,
|
||||
TEST_USERS,
|
||||
CrawlerOptions,
|
||||
} from './utils/crawler';
|
||||
|
||||
// Increase timeout for crawl tests since they visit many pages
|
||||
test.setTimeout(300000); // 5 minutes
|
||||
|
||||
const CRAWLER_OPTIONS: CrawlerOptions = {
|
||||
maxPages: 30, // Limit for CI to keep tests reasonable
|
||||
verbose: false,
|
||||
screenshotOnError: true,
|
||||
screenshotDir: 'test-results/crawler-screenshots',
|
||||
timeout: 15000,
|
||||
waitForNetworkIdle: true,
|
||||
excludePatterns: [
|
||||
/\.(pdf|zip|tar|gz|exe|dmg|pkg)$/i,
|
||||
/^mailto:/i,
|
||||
/^tel:/i,
|
||||
/^javascript:/i,
|
||||
/logout/i,
|
||||
/sign-out/i,
|
||||
// Exclude external links for faster tests
|
||||
/^https?:\/\/(?!.*lvh\.me)/i,
|
||||
],
|
||||
};
|
||||
|
||||
test.describe('Site Crawler - Public Pages', () => {
|
||||
test('should crawl public marketing site without errors', async ({ page, context }) => {
|
||||
const crawler = new SiteCrawler(page, context, {
|
||||
...CRAWLER_OPTIONS,
|
||||
maxPages: 20, // Fewer pages for public site
|
||||
});
|
||||
|
||||
const report = await crawler.crawl('http://lvh.me:5173');
|
||||
|
||||
// Print the report
|
||||
console.log(formatReport(report));
|
||||
|
||||
// Assertions
|
||||
expect(report.totalPages).toBeGreaterThan(0);
|
||||
|
||||
// Filter out minor warnings that aren't real issues
|
||||
const criticalErrors = report.results.flatMap(r =>
|
||||
r.errors.filter(e => {
|
||||
// Ignore React DevTools suggestion
|
||||
if (e.message.includes('React DevTools')) return false;
|
||||
// Ignore favicon 404
|
||||
if (e.message.includes('favicon.ico')) return false;
|
||||
// Ignore hot module replacement messages
|
||||
if (e.message.includes('hot-update') || e.message.includes('hmr')) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
// Fail if there are critical errors
|
||||
if (criticalErrors.length > 0) {
|
||||
console.error('\n❌ Critical errors found:');
|
||||
criticalErrors.forEach(e => {
|
||||
console.error(` - [${e.type}] ${e.url}: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(criticalErrors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Site Crawler - Platform Dashboard', () => {
|
||||
test('should crawl platform dashboard without errors', async ({ page, context }) => {
|
||||
// Login first
|
||||
const user = TEST_USERS.platformSuperuser;
|
||||
const loggedIn = await loginAsUser(page, user);
|
||||
expect(loggedIn).toBe(true);
|
||||
|
||||
const crawler = new SiteCrawler(page, context, CRAWLER_OPTIONS);
|
||||
const report = await crawler.crawl('http://platform.lvh.me:5173');
|
||||
|
||||
// Print the report
|
||||
console.log(formatReport(report));
|
||||
|
||||
// Assertions
|
||||
expect(report.totalPages).toBeGreaterThan(0);
|
||||
|
||||
// Filter out minor warnings
|
||||
const criticalErrors = report.results.flatMap(r =>
|
||||
r.errors.filter(e => {
|
||||
if (e.message.includes('React DevTools')) return false;
|
||||
if (e.message.includes('favicon.ico')) return false;
|
||||
if (e.message.includes('hot-update') || e.message.includes('hmr')) return false;
|
||||
// Ignore WebSocket connection issues in dev
|
||||
if (e.message.includes('WebSocket')) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
if (criticalErrors.length > 0) {
|
||||
console.error('\n❌ Critical errors found:');
|
||||
criticalErrors.forEach(e => {
|
||||
console.error(` - [${e.type}] ${e.url}: ${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
expect(criticalErrors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Uncomment and configure when tenant test users are set up
|
||||
// test.describe('Site Crawler - Tenant Dashboard', () => {
|
||||
// test('should crawl tenant dashboard without errors', async ({ page, context }) => {
|
||||
// const user = TEST_USERS.businessOwner;
|
||||
// const loggedIn = await loginAsUser(page, user);
|
||||
// expect(loggedIn).toBe(true);
|
||||
//
|
||||
// const crawler = new SiteCrawler(page, context, CRAWLER_OPTIONS);
|
||||
// const report = await crawler.crawl('http://demo.lvh.me:5173');
|
||||
//
|
||||
// console.log(formatReport(report));
|
||||
//
|
||||
// expect(report.totalPages).toBeGreaterThan(0);
|
||||
//
|
||||
// const criticalErrors = report.results.flatMap(r =>
|
||||
// r.errors.filter(e => {
|
||||
// if (e.message.includes('React DevTools')) return false;
|
||||
// if (e.message.includes('favicon.ico')) return false;
|
||||
// if (e.message.includes('hot-update')) return false;
|
||||
// if (e.message.includes('WebSocket')) return false;
|
||||
// return true;
|
||||
// })
|
||||
// );
|
||||
//
|
||||
// expect(criticalErrors.length).toBe(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
// test.describe('Site Crawler - Customer Portal', () => {
|
||||
// test('should crawl customer booking portal without errors', async ({ page, context }) => {
|
||||
// // Customer portal might not require auth for the booking pages
|
||||
// const crawler = new SiteCrawler(page, context, {
|
||||
// ...CRAWLER_OPTIONS,
|
||||
// maxPages: 15,
|
||||
// });
|
||||
//
|
||||
// const report = await crawler.crawl('http://demo.lvh.me:5173/book');
|
||||
//
|
||||
// console.log(formatReport(report));
|
||||
//
|
||||
// expect(report.totalPages).toBeGreaterThan(0);
|
||||
//
|
||||
// const criticalErrors = report.results.flatMap(r =>
|
||||
// r.errors.filter(e => {
|
||||
// if (e.message.includes('React DevTools')) return false;
|
||||
// if (e.message.includes('favicon.ico')) return false;
|
||||
// if (e.message.includes('hot-update')) return false;
|
||||
// return true;
|
||||
// })
|
||||
// );
|
||||
//
|
||||
// expect(criticalErrors.length).toBe(0);
|
||||
// });
|
||||
// });
|
||||
|
||||
// Quick smoke test that just checks key pages load
|
||||
test.describe('Site Crawler - Quick Smoke Test', () => {
|
||||
const keyPages = [
|
||||
{ name: 'Home', url: 'http://lvh.me:5173/' },
|
||||
{ name: 'About', url: 'http://lvh.me:5173/about' },
|
||||
{ name: 'Pricing', url: 'http://lvh.me:5173/pricing' },
|
||||
{ name: 'Privacy', url: 'http://lvh.me:5173/privacy' },
|
||||
{ name: 'Terms', url: 'http://lvh.me:5173/terms' },
|
||||
{ name: 'Contact', url: 'http://lvh.me:5173/contact' },
|
||||
];
|
||||
|
||||
for (const { name, url } of keyPages) {
|
||||
test(`${name} page should load without errors`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Capture console errors
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
const text = msg.text();
|
||||
// Filter out non-critical
|
||||
if (!text.includes('React DevTools') && !text.includes('favicon')) {
|
||||
errors.push(`Console: ${text}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Capture page errors
|
||||
page.on('pageerror', error => {
|
||||
errors.push(`Page error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Capture failed requests
|
||||
page.on('requestfailed', request => {
|
||||
const failedUrl = request.url();
|
||||
if (!failedUrl.includes('favicon') && !failedUrl.includes('hot-update')) {
|
||||
errors.push(`Request failed: ${failedUrl}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
|
||||
// Wait a bit for React to fully render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check that page rendered something
|
||||
const bodyContent = await page.locator('body').textContent();
|
||||
expect(bodyContent?.length).toBeGreaterThan(100);
|
||||
|
||||
// Report and fail if errors
|
||||
if (errors.length > 0) {
|
||||
console.error(`\n❌ Errors on ${name} page (${url}):`);
|
||||
errors.forEach(e => console.error(` - ${e}`));
|
||||
}
|
||||
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
568
frontend/tests/e2e/utils/crawler.ts
Normal file
568
frontend/tests/e2e/utils/crawler.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Site Crawler Utility
|
||||
* Crawls the site discovering links and capturing errors
|
||||
*/
|
||||
|
||||
import { Page, BrowserContext } from '@playwright/test';
|
||||
|
||||
export interface CrawlError {
|
||||
url: string;
|
||||
type: 'console' | 'network' | 'broken-link' | 'page-error';
|
||||
message: string;
|
||||
details?: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface CrawlResult {
|
||||
url: string;
|
||||
status: 'success' | 'error' | 'skipped';
|
||||
title?: string;
|
||||
errors: CrawlError[];
|
||||
linksFound: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface CrawlReport {
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
totalPages: number;
|
||||
totalErrors: number;
|
||||
results: CrawlResult[];
|
||||
summary: {
|
||||
consoleErrors: number;
|
||||
networkErrors: number;
|
||||
brokenLinks: number;
|
||||
pageErrors: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CrawlerOptions {
|
||||
maxPages?: number;
|
||||
timeout?: number;
|
||||
excludePatterns?: RegExp[];
|
||||
includeExternalLinks?: boolean;
|
||||
waitForNetworkIdle?: boolean;
|
||||
screenshotOnError?: boolean;
|
||||
screenshotDir?: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: CrawlerOptions = {
|
||||
maxPages: 0, // 0 = unlimited
|
||||
timeout: 30000,
|
||||
excludePatterns: [
|
||||
/\.(pdf|zip|tar|gz|exe|dmg|pkg)$/i,
|
||||
/^mailto:/i,
|
||||
/^tel:/i,
|
||||
/^javascript:/i,
|
||||
/logout/i,
|
||||
/sign-out/i,
|
||||
],
|
||||
includeExternalLinks: false,
|
||||
waitForNetworkIdle: true,
|
||||
screenshotOnError: false,
|
||||
screenshotDir: 'test-results/crawler-screenshots',
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
export class SiteCrawler {
|
||||
private page: Page;
|
||||
private context: BrowserContext;
|
||||
private options: CrawlerOptions;
|
||||
private visited: Set<string> = new Set();
|
||||
private queue: string[] = [];
|
||||
private results: CrawlResult[] = [];
|
||||
private baseUrl: string = '';
|
||||
private baseDomain: string = '';
|
||||
|
||||
constructor(page: Page, context: BrowserContext, options: Partial<CrawlerOptions> = {}) {
|
||||
this.page = page;
|
||||
this.context = context;
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
private log(message: string, ...args: unknown[]) {
|
||||
if (this.options.verbose) {
|
||||
console.log(`[Crawler] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url, this.baseUrl);
|
||||
// Remove hash and trailing slash for comparison
|
||||
parsed.hash = '';
|
||||
let normalized = parsed.href;
|
||||
if (normalized.endsWith('/') && normalized !== parsed.origin + '/') {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private isInternalUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url, this.baseUrl);
|
||||
// Check if it's on the same domain or a subdomain of lvh.me
|
||||
return parsed.hostname.endsWith('lvh.me') ||
|
||||
parsed.hostname === 'localhost' ||
|
||||
parsed.hostname === '127.0.0.1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldCrawl(url: string): boolean {
|
||||
// Skip if already visited
|
||||
if (this.visited.has(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if matches exclude patterns
|
||||
for (const pattern of this.options.excludePatterns || []) {
|
||||
if (pattern.test(url)) {
|
||||
this.log(`Skipping excluded URL: ${url}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip external links unless explicitly included
|
||||
if (!this.options.includeExternalLinks && !this.isInternalUrl(url)) {
|
||||
this.log(`Skipping external URL: ${url}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async extractLinks(): Promise<string[]> {
|
||||
const links = await this.page.evaluate(() => {
|
||||
const anchors = document.querySelectorAll('a[href]');
|
||||
const hrefs: string[] = [];
|
||||
anchors.forEach(anchor => {
|
||||
const href = anchor.getAttribute('href');
|
||||
if (href) {
|
||||
hrefs.push(href);
|
||||
}
|
||||
});
|
||||
return hrefs;
|
||||
});
|
||||
|
||||
// Normalize and filter links
|
||||
const normalizedLinks: string[] = [];
|
||||
for (const link of links) {
|
||||
try {
|
||||
const normalized = this.normalizeUrl(link);
|
||||
if (this.shouldCrawl(normalized)) {
|
||||
normalizedLinks.push(normalized);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(normalizedLinks)];
|
||||
}
|
||||
|
||||
private async crawlPage(url: string): Promise<CrawlResult> {
|
||||
const startTime = Date.now();
|
||||
const errors: CrawlError[] = [];
|
||||
const linksFound: string[] = [];
|
||||
|
||||
this.log(`Crawling: ${url}`);
|
||||
|
||||
// Set up error listeners
|
||||
const consoleHandler = (msg: { type: () => string; text: () => string; location: () => { url: string; lineNumber: number } }) => {
|
||||
const type = msg.type();
|
||||
if (type === 'error' || type === 'warning') {
|
||||
const text = msg.text();
|
||||
// Filter out non-critical warnings
|
||||
if (text.includes('width(-1) and height(-1) of chart')) return; // Recharts initial render warning
|
||||
if (text.includes('WebSocket')) return; // WebSocket connection issues in dev
|
||||
if (text.includes('Cross-Origin-Opener-Policy')) return; // COOP header in dev environment
|
||||
if (text.includes('must use HTTPS') && text.includes('Stripe')) return; // Stripe dev mode warning
|
||||
// Show all backend errors (403, 404, 500) for debugging
|
||||
|
||||
errors.push({
|
||||
url,
|
||||
type: 'console',
|
||||
message: text,
|
||||
details: `${type.toUpperCase()} at ${msg.location().url}:${msg.location().lineNumber}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pageErrorHandler = (error: Error) => {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'page-error',
|
||||
message: error.message,
|
||||
details: error.stack,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
const requestFailedHandler = (request: { url: () => string; failure: () => { errorText: string } | null }) => {
|
||||
const failedUrl = request.url();
|
||||
// Ignore some common non-critical failures
|
||||
if (failedUrl.includes('favicon.ico') || failedUrl.includes('hot-update')) {
|
||||
return;
|
||||
}
|
||||
// Ignore Stripe external requests (tracking/monitoring that gets cancelled)
|
||||
if (failedUrl.includes('stripe.com') || failedUrl.includes('stripe.network')) {
|
||||
return;
|
||||
}
|
||||
// Show all API failures for debugging
|
||||
errors.push({
|
||||
url,
|
||||
type: 'network',
|
||||
message: `Request failed: ${failedUrl}`,
|
||||
details: request.failure()?.errorText || 'Unknown error',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
const responseHandler = (response: { url: () => string; status: () => number }) => {
|
||||
const status = response.status();
|
||||
const responseUrl = response.url();
|
||||
// Track 4xx and 5xx responses (excluding some common benign ones)
|
||||
if (status >= 400 && !responseUrl.includes('favicon.ico')) {
|
||||
// Show all API errors (403, 404, 500) for debugging
|
||||
|
||||
errors.push({
|
||||
url,
|
||||
type: 'network',
|
||||
message: `HTTP ${status}: ${responseUrl}`,
|
||||
details: `Response status ${status}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.page.on('console', consoleHandler);
|
||||
this.page.on('pageerror', pageErrorHandler);
|
||||
this.page.on('requestfailed', requestFailedHandler);
|
||||
this.page.on('response', responseHandler);
|
||||
|
||||
try {
|
||||
// Navigate to the page
|
||||
const response = await this.page.goto(url, {
|
||||
timeout: this.options.timeout,
|
||||
waitUntil: this.options.waitForNetworkIdle ? 'networkidle' : 'domcontentloaded',
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'network',
|
||||
message: 'No response received',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
return {
|
||||
url,
|
||||
status: 'error',
|
||||
errors,
|
||||
linksFound,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const status = response.status();
|
||||
if (status >= 400) {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'broken-link',
|
||||
message: `HTTP ${status}`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Wait a bit for React to render and any async operations
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
// Get page title
|
||||
const title = await this.page.title();
|
||||
|
||||
// Extract links
|
||||
const links = await this.extractLinks();
|
||||
linksFound.push(...links);
|
||||
|
||||
// Add new links to queue
|
||||
for (const link of links) {
|
||||
if (!this.visited.has(link) && !this.queue.includes(link)) {
|
||||
this.queue.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
// Screenshot on error if enabled
|
||||
if (errors.length > 0 && this.options.screenshotOnError) {
|
||||
const filename = url.replace(/[^a-zA-Z0-9]/g, '_').substring(0, 100);
|
||||
await this.page.screenshot({
|
||||
path: `${this.options.screenshotDir}/${filename}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
status: errors.length > 0 ? 'error' : 'success',
|
||||
title,
|
||||
errors,
|
||||
linksFound,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
url,
|
||||
type: 'page-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
status: 'error',
|
||||
errors,
|
||||
linksFound,
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
} finally {
|
||||
// Remove listeners
|
||||
this.page.off('console', consoleHandler);
|
||||
this.page.off('pageerror', pageErrorHandler);
|
||||
this.page.off('requestfailed', requestFailedHandler);
|
||||
this.page.off('response', responseHandler);
|
||||
}
|
||||
}
|
||||
|
||||
async crawl(startUrl: string): Promise<CrawlReport> {
|
||||
const startTime = new Date();
|
||||
this.baseUrl = startUrl;
|
||||
this.baseDomain = new URL(startUrl).hostname;
|
||||
this.queue = [this.normalizeUrl(startUrl)];
|
||||
this.visited.clear();
|
||||
this.results = [];
|
||||
|
||||
console.log(`\n🕷️ Starting crawl from: ${startUrl}`);
|
||||
console.log(` Max pages: ${this.options.maxPages || 'unlimited'}`);
|
||||
console.log('');
|
||||
|
||||
const maxPages = this.options.maxPages || 0; // 0 = unlimited
|
||||
while (this.queue.length > 0 && (maxPages === 0 || this.results.length < maxPages)) {
|
||||
const url = this.queue.shift()!;
|
||||
|
||||
if (this.visited.has(url)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.visited.add(url);
|
||||
const result = await this.crawlPage(url);
|
||||
this.results.push(result);
|
||||
|
||||
// Progress indicator
|
||||
const errorCount = result.errors.length;
|
||||
const statusIcon = result.status === 'success' ? '✓' : '✗';
|
||||
const errorInfo = errorCount > 0 ? ` (${errorCount} error${errorCount > 1 ? 's' : ''})` : '';
|
||||
const maxDisplay = maxPages === 0 ? '∞' : maxPages;
|
||||
console.log(` ${statusIcon} [${this.results.length}/${maxDisplay}] ${url}${errorInfo}`);
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
consoleErrors: 0,
|
||||
networkErrors: 0,
|
||||
brokenLinks: 0,
|
||||
pageErrors: 0,
|
||||
};
|
||||
|
||||
for (const result of this.results) {
|
||||
for (const error of result.errors) {
|
||||
switch (error.type) {
|
||||
case 'console':
|
||||
summary.consoleErrors++;
|
||||
break;
|
||||
case 'network':
|
||||
summary.networkErrors++;
|
||||
break;
|
||||
case 'broken-link':
|
||||
summary.brokenLinks++;
|
||||
break;
|
||||
case 'page-error':
|
||||
summary.pageErrors++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
totalPages: this.results.length,
|
||||
totalErrors: summary.consoleErrors + summary.networkErrors + summary.brokenLinks + summary.pageErrors,
|
||||
results: this.results,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReport(report: CrawlReport): string {
|
||||
const duration = (report.endTime.getTime() - report.startTime.getTime()) / 1000;
|
||||
|
||||
let output = '\n';
|
||||
output += '═'.repeat(60) + '\n';
|
||||
output += ' CRAWL REPORT\n';
|
||||
output += '═'.repeat(60) + '\n\n';
|
||||
|
||||
output += `📊 Summary\n`;
|
||||
output += ` Pages crawled: ${report.totalPages}\n`;
|
||||
output += ` Total errors: ${report.totalErrors}\n`;
|
||||
output += ` Duration: ${duration.toFixed(1)}s\n\n`;
|
||||
|
||||
output += `📋 Error Breakdown\n`;
|
||||
output += ` Console errors: ${report.summary.consoleErrors}\n`;
|
||||
output += ` Network errors: ${report.summary.networkErrors}\n`;
|
||||
output += ` Broken links: ${report.summary.brokenLinks}\n`;
|
||||
output += ` Page errors: ${report.summary.pageErrors}\n\n`;
|
||||
|
||||
// List pages with errors
|
||||
const pagesWithErrors = report.results.filter(r => r.errors.length > 0);
|
||||
if (pagesWithErrors.length > 0) {
|
||||
output += '─'.repeat(60) + '\n';
|
||||
output += ' ERROR DETAILS\n';
|
||||
output += '─'.repeat(60) + '\n\n';
|
||||
|
||||
for (const result of pagesWithErrors) {
|
||||
output += `🔗 ${result.url}\n`;
|
||||
output += ` Title: ${result.title || 'N/A'}\n`;
|
||||
|
||||
for (const error of result.errors) {
|
||||
const icon = error.type === 'console' ? '⚠️' :
|
||||
error.type === 'network' ? '🌐' :
|
||||
error.type === 'broken-link' ? '🔴' : '💥';
|
||||
output += ` ${icon} [${error.type.toUpperCase()}] ${error.message}\n`;
|
||||
if (error.details) {
|
||||
output += ` Details: ${error.details.substring(0, 200)}\n`;
|
||||
}
|
||||
}
|
||||
output += '\n';
|
||||
}
|
||||
} else {
|
||||
output += '✅ No errors found!\n\n';
|
||||
}
|
||||
|
||||
output += '═'.repeat(60) + '\n';
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Authentication helpers for different user types
|
||||
export interface UserCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
loginUrl: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const TEST_USERS: Record<string, UserCredentials> = {
|
||||
platformSuperuser: {
|
||||
username: 'poduck@gmail.com',
|
||||
password: 'starry12',
|
||||
loginUrl: 'http://platform.lvh.me:5173/platform/login',
|
||||
description: 'Platform Superuser',
|
||||
},
|
||||
businessOwner: {
|
||||
username: 'owner@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Business Owner',
|
||||
},
|
||||
businessManager: {
|
||||
username: 'manager@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Business Manager',
|
||||
},
|
||||
businessStaff: {
|
||||
username: 'staff@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Staff Member',
|
||||
},
|
||||
customer: {
|
||||
username: 'customer@demo.com',
|
||||
password: 'password123',
|
||||
loginUrl: 'http://demo.lvh.me:5173/login',
|
||||
description: 'Customer',
|
||||
},
|
||||
};
|
||||
|
||||
export async function loginAsUser(page: Page, user: UserCredentials): Promise<boolean> {
|
||||
console.log(`\n🔐 Logging in as ${user.description}...`);
|
||||
console.log(` Login URL: ${user.loginUrl}`);
|
||||
|
||||
try {
|
||||
await page.goto(user.loginUrl);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if already logged in (dashboard visible)
|
||||
const isDashboard = page.url().includes('/dashboard') ||
|
||||
await page.getByRole('heading', { name: /dashboard/i }).isVisible().catch(() => false);
|
||||
|
||||
if (isDashboard) {
|
||||
console.log(` ✓ Already logged in. Current URL: ${page.url()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try quick login buttons first (dev mode)
|
||||
const quickLoginButton = page.getByRole('button', { name: new RegExp(user.description, 'i') });
|
||||
const hasQuickLogin = await quickLoginButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasQuickLogin) {
|
||||
console.log(` Using quick login button for ${user.description}...`);
|
||||
await quickLoginButton.click();
|
||||
} else {
|
||||
// Fall back to form login
|
||||
let emailInput = page.locator('#email');
|
||||
let passwordInput = page.locator('#password');
|
||||
|
||||
const formFound = await emailInput.waitFor({ timeout: 10000 }).then(() => true).catch(() => false);
|
||||
|
||||
if (!formFound) {
|
||||
emailInput = page.getByPlaceholder(/enter your email/i);
|
||||
passwordInput = page.getByPlaceholder(/password/i);
|
||||
await emailInput.waitFor({ timeout: 5000 });
|
||||
}
|
||||
|
||||
await emailInput.fill(user.username);
|
||||
await passwordInput.fill(user.password);
|
||||
await page.getByRole('button', { name: /^sign in$/i }).click();
|
||||
}
|
||||
|
||||
// Wait for navigation after login
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify we're logged in (not on login page anymore)
|
||||
const currentUrl = page.url();
|
||||
const isLoggedIn = !currentUrl.includes('/login') &&
|
||||
!currentUrl.endsWith(':5173/') &&
|
||||
!currentUrl.endsWith(':5173');
|
||||
|
||||
if (isLoggedIn) {
|
||||
console.log(` ✓ Logged in successfully. Current URL: ${currentUrl}`);
|
||||
} else {
|
||||
console.log(` ✗ Login may have failed. Current URL: ${currentUrl}`);
|
||||
}
|
||||
|
||||
return isLoggedIn;
|
||||
} catch (error) {
|
||||
console.error(` ✗ Login failed:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user