feat: Add plugin configuration editing with template variable parsing
## Backend Changes:
- Enhanced PluginTemplate.save() to auto-parse template variables from plugin code
- Updated PluginInstallationSerializer to expose template metadata (description, category, version, author, logo, template_variables)
- Fixed template variable parser to handle nested {{ }} braces in default values
- Added brace-counting algorithm to properly extract variables with insertion codes
- Fixed explicit type parameter detection (textarea, text, email, etc.)
- Made scheduled_task optional on PluginInstallation model
- Added EventPlugin through model for event-plugin relationships
- Added Event.execute_plugins() method for plugin automation
## Frontend Changes:
- Created Tasks.tsx page for managing scheduled tasks
- Enhanced MyPlugins page with clickable plugin cards
- Added edit configuration modal with dynamic form generation
- Implemented escape sequence handling (convert \n, \', etc. for display)
- Added plugin logos to My Plugins page
- Updated type definitions for PluginInstallation interface
- Added insertion code documentation to Plugin Docs
## Plugin System:
- All platform plugins now have editable email templates with textarea support
- Template variables properly parsed with full default values
- Insertion codes ({{CUSTOMER_NAME}}, {{BUSINESS_NAME}}, etc.) documented
- Plugin logos displayed in marketplace and My Plugins
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
300
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@stripe/connect-js": "^3.3.31",
|
"@stripe/connect-js": "^3.3.31",
|
||||||
"@stripe/react-connect-js": "^3.3.31",
|
"@stripe/react-connect-js": "^3.3.31",
|
||||||
"@tanstack/react-query": "^5.90.10",
|
"@tanstack/react-query": "^5.90.10",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"i18next": "^25.6.3",
|
"i18next": "^25.6.3",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"react-i18next": "^16.3.5",
|
"react-i18next": "^16.3.5",
|
||||||
"react-phone-number-input": "^3.4.14",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"recharts": "^3.5.0"
|
"recharts": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1882,6 +1884,15 @@
|
|||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/hast": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -1898,11 +1909,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/prismjs": {
|
||||||
|
"version": "1.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
|
||||||
|
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
|
||||||
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1918,6 +1934,21 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-syntax-highlighter": {
|
||||||
|
"version": "15.5.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||||
|
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/unist": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -2177,6 +2208,36 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/character-entities": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/character-entities-legacy": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/character-reference-invalid": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/classnames": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
@@ -2222,6 +2283,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/comma-separated-tokens": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -2433,6 +2504,19 @@
|
|||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decode-named-character-reference": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"character-entities": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -2824,6 +2908,19 @@
|
|||||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fault": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"format": "^0.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -2925,6 +3022,14 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/format": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -3111,6 +3216,36 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-parse-selector": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hastscript": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"comma-separated-tokens": "^2.0.0",
|
||||||
|
"hast-util-parse-selector": "^4.0.0",
|
||||||
|
"property-information": "^7.0.0",
|
||||||
|
"space-separated-tokens": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -3128,6 +3263,21 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "10.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||||
|
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/highlightjs-vue": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||||
@@ -3260,6 +3410,40 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-alphabetical": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-alphanumerical": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-alphabetical": "^2.0.0",
|
||||||
|
"is-decimal": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-decimal": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -3281,6 +3465,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-hexadecimal": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -3681,6 +3875,20 @@
|
|||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lowlight": {
|
||||||
|
"version": "1.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||||
|
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fault": "^1.0.0",
|
||||||
|
"highlight.js": "~10.7.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -3888,6 +4096,31 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-entities": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^2.0.0",
|
||||||
|
"character-entities-legacy": "^3.0.0",
|
||||||
|
"character-reference-invalid": "^2.0.0",
|
||||||
|
"decode-named-character-reference": "^1.0.0",
|
||||||
|
"is-alphanumerical": "^2.0.0",
|
||||||
|
"is-decimal": "^2.0.0",
|
||||||
|
"is-hexadecimal": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse-entities/node_modules/@types/unist": {
|
||||||
|
"version": "2.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
|
||||||
|
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -4003,6 +4236,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prismjs": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -4020,6 +4262,16 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/property-information": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -4195,6 +4447,26 @@
|
|||||||
"react-dom": ">=18"
|
"react-dom": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-syntax-highlighter": {
|
||||||
|
"version": "16.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
|
||||||
|
"integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"highlight.js": "^10.4.1",
|
||||||
|
"highlightjs-vue": "^1.0.0",
|
||||||
|
"lowlight": "^1.17.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
|
"refractor": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.20.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 0.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.0.tgz",
|
||||||
@@ -4241,6 +4513,22 @@
|
|||||||
"redux": "^5.0.0"
|
"redux": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/refractor": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/prismjs": "^1.0.0",
|
||||||
|
"hastscript": "^9.0.0",
|
||||||
|
"parse-entities": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reselect": {
|
"node_modules/reselect": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
@@ -4351,6 +4639,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/space-separated-tokens": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"@stripe/connect-js": "^3.3.31",
|
"@stripe/connect-js": "^3.3.31",
|
||||||
"@stripe/react-connect-js": "^3.3.31",
|
"@stripe/react-connect-js": "^3.3.31",
|
||||||
"@tanstack/react-query": "^5.90.10",
|
"@tanstack/react-query": "^5.90.10",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"i18next": "^25.6.3",
|
"i18next": "^25.6.3",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"react-i18next": "^16.3.5",
|
"react-i18next": "^16.3.5",
|
||||||
"react-phone-number-input": "^3.4.14",
|
"react-phone-number-input": "^3.4.14",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"recharts": "^3.5.0"
|
"recharts": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
BIN
frontend/public/plugin-logos/appointment-reminder-24hr.png
Normal file
|
After Width: | Height: | Size: 993 B |
1
frontend/public/plugin-logos/appointment_reminder.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-bell"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
|
||||||
|
After Width: | Height: | Size: 299 B |
1
frontend/public/plugin-logos/backup_database.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-database"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s 9-1.34 9-3V5"></path></svg>
|
||||||
|
After Width: | Height: | Size: 351 B |
BIN
frontend/public/plugin-logos/birthday-greetings.png
Normal file
|
After Width: | Height: | Size: 840 B |
1
frontend/public/plugin-logos/cleanup_old_events.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
|
||||||
|
After Width: | Height: | Size: 426 B |
BIN
frontend/public/plugin-logos/daily-appointment-summary.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
frontend/public/plugin-logos/daily_report.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file-text"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 451 B |
132
frontend/public/plugin-logos/generate_all_logos_gemini.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Setup Client
|
||||||
|
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||||
|
|
||||||
|
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||||
|
|
||||||
|
# Plugin configurations with detailed prompts
|
||||||
|
plugins = [
|
||||||
|
{
|
||||||
|
'filename': 'daily-appointment-summary.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||||
|
Design: Indigo blue gradient background (#4f46e5 to lighter blue).
|
||||||
|
Icon: Simple white envelope overlaid with a small calendar icon in the corner.
|
||||||
|
Style: Flat design, clean geometric shapes, professional SaaS aesthetic.
|
||||||
|
The icon should be crisp and recognizable at 48x48 pixels.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'no-show-tracker.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||||
|
Design: Red gradient background (#dc2626 to darker red).
|
||||||
|
Icon: Simple white person silhouette with a bold white X symbol overlaid.
|
||||||
|
Style: Flat design, clean geometric shapes, professional warning aesthetic.
|
||||||
|
The icon should clearly convey "missed appointment" at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'birthday-greetings.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||||
|
Design: Pink gradient background (#ec4899 to lighter pink).
|
||||||
|
Icon: Simple white birthday cake with 3 candles or a white gift box with a bow.
|
||||||
|
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
|
||||||
|
The icon should feel celebratory and friendly at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'monthly-revenue-report.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||||
|
Design: Green gradient background (#10b981 to darker green).
|
||||||
|
Icon: Simple white upward trending line chart or ascending bar graph.
|
||||||
|
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
|
||||||
|
The icon should convey growth and success at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'appointment-reminder-24hr.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||||
|
Design: Amber/orange gradient background (#f59e0b to darker orange).
|
||||||
|
Icon: Simple white notification bell with a small red circular alert badge.
|
||||||
|
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
|
||||||
|
The icon should clearly indicate alerts and reminders at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'inactive-customer-reengagement.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in square format with rounded corners.
|
||||||
|
Design: Purple gradient background (#8b5cf6 to darker purple).
|
||||||
|
Icon: Simple white heart symbol with a circular white refresh/return arrow around it.
|
||||||
|
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
|
||||||
|
The icon should convey customer care and returning customers at small sizes.'''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_logo(prompt, filename):
|
||||||
|
"""Generate a logo using Gemini 2.5 Flash Image"""
|
||||||
|
print(f"\nGenerating {filename}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model='gemini-2.5-flash-image',
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["IMAGE"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the Image
|
||||||
|
if response.candidates[0].content.parts:
|
||||||
|
for part in response.candidates[0].content.parts:
|
||||||
|
if part.inline_data:
|
||||||
|
image_data = part.inline_data.data
|
||||||
|
image = Image.open(BytesIO(image_data))
|
||||||
|
|
||||||
|
# Save to output directory
|
||||||
|
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||||
|
image.save(output_path)
|
||||||
|
print(f"✓ Saved: {output_path}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"✗ Model returned text instead of image: {part.text[:100]}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print("✗ No content parts in response")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if "RESOURCE_EXHAUSTED" in error_msg or "quota" in error_msg.lower():
|
||||||
|
print(f"✗ Quota exceeded. Please try again tomorrow when quota resets.")
|
||||||
|
print(f" Error: {error_msg[:200]}...")
|
||||||
|
else:
|
||||||
|
print(f"✗ Error: {type(e).__name__}: {error_msg[:200]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("Generating Plugin Logos with Gemini 2.5 Flash Image")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for i, plugin in enumerate(plugins):
|
||||||
|
if i > 0:
|
||||||
|
# Wait 3 seconds between requests to avoid rate limiting
|
||||||
|
print("\nWaiting 3 seconds before next request...")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
if generate_logo(plugin['prompt'], plugin['filename']):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
if success_count == 0:
|
||||||
|
print("\nNote: If you hit quota limits, try again tomorrow when the quota resets!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
77
frontend/public/plugin-logos/generate_daily_summary.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
except ImportError:
|
||||||
|
print("Error: Pillow library not found.")
|
||||||
|
print("Please install it using: pip install Pillow")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
OUTPUT_PATH = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos/daily-appointment-summary.png"
|
||||||
|
SIZE = (144, 144) # Generated at 3x resolution (144px) for high quality on 48px displays
|
||||||
|
BG_COLOR = "#4f46e5" # Indigo/Blue
|
||||||
|
ICON_COLOR = "white"
|
||||||
|
|
||||||
|
def create_logo():
|
||||||
|
# Create a new image with a transparent background
|
||||||
|
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 1. Draw Background (Rounded Square)
|
||||||
|
radius = 30
|
||||||
|
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
|
||||||
|
draw.rounded_rectangle(rect_bounds, radius=radius, fill=BG_COLOR)
|
||||||
|
|
||||||
|
# 2. Draw Envelope Icon (Email concept)
|
||||||
|
# Centered, slightly offset up to make room for calendar
|
||||||
|
env_w = 90
|
||||||
|
env_h = 60
|
||||||
|
env_x = (SIZE[0] - env_w) // 2
|
||||||
|
env_y = (SIZE[1] - env_h) // 2 - 10
|
||||||
|
|
||||||
|
# Envelope body
|
||||||
|
draw.rectangle([env_x, env_y, env_x + env_w, env_y + env_h], outline=ICON_COLOR, width=5)
|
||||||
|
|
||||||
|
# Envelope flap (V shape)
|
||||||
|
draw.line([env_x, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
|
||||||
|
draw.line([env_x + env_w, env_y, env_x + env_w // 2, env_y + env_h // 2 + 5], fill=ICON_COLOR, width=5)
|
||||||
|
|
||||||
|
# 3. Draw Calendar Badge (Scheduling concept)
|
||||||
|
# Bottom right corner
|
||||||
|
cal_size = 50
|
||||||
|
cal_x = env_x + env_w - (cal_size // 2)
|
||||||
|
cal_y = env_y + env_h - (cal_size // 2)
|
||||||
|
|
||||||
|
# Clear background behind calendar for separation
|
||||||
|
border = 4
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[cal_x - border, cal_y - border, cal_x + cal_size + border, cal_y + cal_size + border],
|
||||||
|
radius=10, fill=BG_COLOR
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calendar body
|
||||||
|
draw.rounded_rectangle([cal_x, cal_y, cal_x + cal_size, cal_y + cal_size], radius=8, fill="white")
|
||||||
|
|
||||||
|
# Calendar red header (using a lighter indigo/blue to match theme)
|
||||||
|
header_h = 14
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[cal_x, cal_y, cal_x + cal_size, cal_y + header_h],
|
||||||
|
radius=8, corners=(True, True, False, False), fill="#818cf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calendar 'lines'
|
||||||
|
line_x = cal_x + 10
|
||||||
|
line_w = cal_size - 20
|
||||||
|
draw.line([line_x, cal_y + 22, line_x + line_w, cal_y + 22], fill=BG_COLOR, width=3)
|
||||||
|
draw.line([line_x, cal_y + 32, line_x + line_w, cal_y + 32], fill=BG_COLOR, width=3)
|
||||||
|
draw.line([line_x, cal_y + 42, line_x + line_w * 0.6, cal_y + 42], fill=BG_COLOR, width=3)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||||
|
img.save(OUTPUT_PATH)
|
||||||
|
print(f"Success! Logo saved to: {OUTPUT_PATH}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_logo()
|
||||||
155
frontend/public/plugin-logos/generate_plugin_logos.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except ImportError:
|
||||||
|
print("Error: Pillow library not found.")
|
||||||
|
print("Please install it using: pip install Pillow")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||||
|
SIZE = (144, 144)
|
||||||
|
RADIUS = 30
|
||||||
|
|
||||||
|
def create_base_logo(bg_color):
|
||||||
|
"""Creates the base image with rounded corners and background color."""
|
||||||
|
img = Image.new('RGBA', SIZE, (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
rect_bounds = [0, 0, SIZE[0], SIZE[1]]
|
||||||
|
draw.rounded_rectangle(rect_bounds, radius=RADIUS, fill=bg_color)
|
||||||
|
return img, draw
|
||||||
|
|
||||||
|
def save_logo(img, filename):
|
||||||
|
"""Saves the image to the output directory."""
|
||||||
|
path = os.path.join(OUTPUT_DIR, filename)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
img.save(path)
|
||||||
|
print(f"Saved: {path}")
|
||||||
|
|
||||||
|
# 1. No-Show Customer Tracker (Red, Person with X)
|
||||||
|
def create_no_show_logo():
|
||||||
|
img, draw = create_base_logo("#dc2626") # Red
|
||||||
|
|
||||||
|
# Person Silhouette
|
||||||
|
cx, cy = 72, 72
|
||||||
|
# Head
|
||||||
|
draw.ellipse([cx-15, 35, cx+15, 65], fill="white")
|
||||||
|
# Shoulders/Body
|
||||||
|
draw.rounded_rectangle([cx-30, 70, cx+30, 130], radius=10, fill="white")
|
||||||
|
|
||||||
|
# Cancel Symbol (Red X on white circle or just overlaid)
|
||||||
|
# Let's use a thick Red X overlay
|
||||||
|
x_center_y = 85
|
||||||
|
half = 20
|
||||||
|
width = 8
|
||||||
|
|
||||||
|
# Draw X
|
||||||
|
draw.line([cx - half, x_center_y - half, cx + half, x_center_y + half], fill="#dc2626", width=width)
|
||||||
|
draw.line([cx + half, x_center_y - half, cx - half, x_center_y + half], fill="#dc2626", width=width)
|
||||||
|
|
||||||
|
save_logo(img, "no-show-tracker.png")
|
||||||
|
|
||||||
|
# 2. Birthday Greeting Campaign (Pink, Gift)
|
||||||
|
def create_birthday_logo():
|
||||||
|
img, draw = create_base_logo("#ec4899") # Pink
|
||||||
|
|
||||||
|
# Gift Box
|
||||||
|
box_w, box_h = 70, 60
|
||||||
|
box_x = (SIZE[0] - box_w) // 2
|
||||||
|
box_y = (SIZE[1] - box_h) // 2 + 10
|
||||||
|
|
||||||
|
# Box body
|
||||||
|
draw.rectangle([box_x, box_y, box_x + box_w, box_y + box_h], fill="white")
|
||||||
|
|
||||||
|
# Ribbons (Lighter Pink)
|
||||||
|
r_w = 12
|
||||||
|
ribbon_color = "#fbcfe8"
|
||||||
|
# Vertical
|
||||||
|
draw.rectangle([box_x + (box_w - r_w)//2, box_y, box_x + (box_w + r_w)//2, box_y + box_h], fill=ribbon_color)
|
||||||
|
# Horizontal
|
||||||
|
draw.rectangle([box_x, box_y + (box_h - r_w)//2, box_x + box_w, box_y + (box_h + r_w)//2], fill=ribbon_color)
|
||||||
|
|
||||||
|
# Bow
|
||||||
|
bow_w = 20
|
||||||
|
draw.ellipse([72 - bow_w, box_y - 15, 72, box_y], fill="white")
|
||||||
|
draw.ellipse([72, box_y - 15, 72 + bow_w, box_y], fill="white")
|
||||||
|
|
||||||
|
save_logo(img, "birthday-greetings.png")
|
||||||
|
|
||||||
|
# 3. Monthly Revenue Report (Green, Chart)
|
||||||
|
def create_revenue_logo():
|
||||||
|
img, draw = create_base_logo("#10b981") # Green
|
||||||
|
|
||||||
|
# Bar Chart
|
||||||
|
margin_bottom = 110
|
||||||
|
bar_w = 20
|
||||||
|
gap = 10
|
||||||
|
start_x = (144 - (3 * bar_w + 2 * gap)) // 2
|
||||||
|
|
||||||
|
# Bars
|
||||||
|
heights = [30, 50, 75]
|
||||||
|
for i, h in enumerate(heights):
|
||||||
|
x = start_x + i * (bar_w + gap)
|
||||||
|
draw.rectangle([x, margin_bottom - h, x + bar_w, margin_bottom], fill="white")
|
||||||
|
|
||||||
|
# Trend Arrow (Rising)
|
||||||
|
arrow_start = (start_x - 5, margin_bottom - 10)
|
||||||
|
arrow_end = (start_x + 3 * bar_w + 2 * gap + 5, margin_bottom - 90)
|
||||||
|
|
||||||
|
# Simple line for trend
|
||||||
|
# draw.line([arrow_start, arrow_end], fill="white", width=4)
|
||||||
|
|
||||||
|
save_logo(img, "monthly-revenue-report.png")
|
||||||
|
|
||||||
|
# 4. Appointment Reminder (24hr) (Amber, Bell)
|
||||||
|
def create_reminder_logo():
|
||||||
|
img, draw = create_base_logo("#f59e0b") # Amber
|
||||||
|
|
||||||
|
cx, cy = 72, 70
|
||||||
|
r = 30
|
||||||
|
|
||||||
|
# Bell Dome
|
||||||
|
draw.chord([cx - r, cy - r, cx + r, cy + r], 180, 0, fill="white")
|
||||||
|
draw.rectangle([cx - r, cy, cx + r, cy + 20], fill="white")
|
||||||
|
|
||||||
|
# Bell Flare
|
||||||
|
draw.polygon([(cx - r, cy + 20), (cx + r, cy + 20), (cx + r + 5, cy + 30), (cx - r - 5, cy + 30)], fill="white")
|
||||||
|
|
||||||
|
# Clapper
|
||||||
|
draw.ellipse([cx - 8, cy + 28, cx + 8, cy + 42], fill="white")
|
||||||
|
|
||||||
|
# Notification Dot
|
||||||
|
draw.ellipse([cx + 15, cy - 25, cx + 35, cy - 5], fill="#dc2626")
|
||||||
|
|
||||||
|
save_logo(img, "appointment-reminder-24hr.png")
|
||||||
|
|
||||||
|
# 5. Inactive Customer Re-engagement (Purple, Heart)
|
||||||
|
def create_inactive_logo():
|
||||||
|
img, draw = create_base_logo("#8b5cf6") # Purple
|
||||||
|
|
||||||
|
hx, hy = 72, 75
|
||||||
|
size = 18
|
||||||
|
|
||||||
|
# Heart Shape
|
||||||
|
# Left circle
|
||||||
|
draw.ellipse([hx - size * 2, hy - size, hx, hy + size], fill="white")
|
||||||
|
# Right circle
|
||||||
|
draw.ellipse([hx, hy - size, hx + size * 2, hy + size], fill="white")
|
||||||
|
# Bottom triangle
|
||||||
|
draw.polygon([(hx - size * 2 + 1, hy + 4), (hx + size * 2 - 1, hy + 4), (hx, hy + size * 2 + 8)], fill="white")
|
||||||
|
|
||||||
|
# Refresh/Loop Arrow (Subtle arc above)
|
||||||
|
bbox = [25, 25, 119, 119]
|
||||||
|
draw.arc(bbox, start=180, end=0, fill="#ddd6fe", width=5)
|
||||||
|
draw.polygon([(119, 72), (110, 60), (128, 60)], fill="#ddd6fe")
|
||||||
|
|
||||||
|
save_logo(img, "inactive-customer-reengagement.png")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_no_show_logo()
|
||||||
|
create_birthday_logo()
|
||||||
|
create_revenue_logo()
|
||||||
|
create_reminder_logo()
|
||||||
|
create_inactive_logo()
|
||||||
39
frontend/public/plugin-logos/generate_with_2_0_flash.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
# Setup Client
|
||||||
|
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||||
|
|
||||||
|
# Test with a simple prompt
|
||||||
|
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
|
||||||
|
|
||||||
|
print(f"Testing gemini-2.0-flash-exp...")
|
||||||
|
print(f"Prompt: '{prompt}'...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model='gemini-2.0-flash-exp',
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["IMAGE"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the Image
|
||||||
|
if response.candidates[0].content.parts:
|
||||||
|
for part in response.candidates[0].content.parts:
|
||||||
|
if part.inline_data:
|
||||||
|
image_data = part.inline_data.data
|
||||||
|
image = Image.open(BytesIO(image_data))
|
||||||
|
image.save("test_2_0_flash.png")
|
||||||
|
print("✓ Success! Saved test_2_0_flash.png")
|
||||||
|
else:
|
||||||
|
print(f"✗ Model returned text: {part.text[:100]}")
|
||||||
|
else:
|
||||||
|
print("✗ No content parts in response")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {type(e).__name__}: {str(e)[:300]}")
|
||||||
194
frontend/public/plugin-logos/generate_with_gemini.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Gemini API configuration
|
||||||
|
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
|
||||||
|
API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent"
|
||||||
|
|
||||||
|
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||||
|
|
||||||
|
# Plugin configurations
|
||||||
|
plugins = [
|
||||||
|
{
|
||||||
|
'filename': 'daily-appointment-summary.png',
|
||||||
|
'prompt': '''Create a modern, professional icon/logo for a plugin called "Daily Appointment Summary Email".
|
||||||
|
|
||||||
|
Design requirements:
|
||||||
|
- Square icon, 512x512 pixels
|
||||||
|
- Flat design style with a slight gradient
|
||||||
|
- Primary color: Indigo/blue (#4f46e5)
|
||||||
|
- Icon should combine email and calendar/scheduling concepts
|
||||||
|
- Clean, minimalist design suitable for a SaaS application
|
||||||
|
- White or light elements on the colored background
|
||||||
|
- Rounded corners (similar to modern app icons)
|
||||||
|
- Professional and trustworthy appearance
|
||||||
|
|
||||||
|
The icon represents a plugin that sends daily email summaries of appointments to staff members.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'no-show-tracker.png',
|
||||||
|
'prompt': '''Create a modern, professional icon/logo for a plugin called "No-Show Customer Tracker".
|
||||||
|
|
||||||
|
Design requirements:
|
||||||
|
- Square icon, 512x512 pixels
|
||||||
|
- Flat design style with a slight gradient
|
||||||
|
- Primary color: Red (#dc2626)
|
||||||
|
- Icon should represent missed appointments or absent customers
|
||||||
|
- Could show a person silhouette with an X, slash, or cancel symbol
|
||||||
|
- Clean, minimalist design suitable for a SaaS application
|
||||||
|
- White or light elements on the colored background
|
||||||
|
- Rounded corners (similar to modern app icons)
|
||||||
|
- Professional appearance
|
||||||
|
|
||||||
|
The icon represents a plugin that tracks customers who miss their appointments.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'birthday-greetings.png',
|
||||||
|
'prompt': '''Create a modern, professional icon/logo for a plugin called "Birthday Greeting Campaign".
|
||||||
|
|
||||||
|
Design requirements:
|
||||||
|
- Square icon, 512x512 pixels
|
||||||
|
- Flat design style with a slight gradient
|
||||||
|
- Primary color: Pink (#ec4899)
|
||||||
|
- Icon should represent birthdays and celebrations
|
||||||
|
- Could show a birthday cake, gift, party hat, or balloon
|
||||||
|
- Clean, minimalist design suitable for a SaaS application
|
||||||
|
- White or light elements on the colored background
|
||||||
|
- Rounded corners (similar to modern app icons)
|
||||||
|
- Friendly and celebratory appearance
|
||||||
|
|
||||||
|
The icon represents a plugin that sends birthday emails with special offers to customers.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'monthly-revenue-report.png',
|
||||||
|
'prompt': '''Create a modern, professional icon/logo for a plugin called "Monthly Revenue Report".
|
||||||
|
|
||||||
|
Design requirements:
|
||||||
|
- Square icon, 512x512 pixels
|
||||||
|
- Flat design style with a slight gradient
|
||||||
|
- Primary color: Green (#10b981)
|
||||||
|
- Icon should represent business growth, analytics, and financial reporting
|
||||||
|
- Could show an upward trending chart, graph, or money symbol
|
||||||
|
- Clean, minimalist design suitable for a SaaS application
|
||||||
|
- White or light elements on the colored background
|
||||||
|
- Rounded corners (similar to modern app icons)
|
||||||
|
- Professional and successful appearance
|
||||||
|
|
||||||
|
The icon represents a plugin that generates comprehensive monthly business statistics and revenue reports.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'appointment-reminder-24hr.png',
|
||||||
|
'prompt': '''Create a modern, professional icon/logo for a plugin called "Appointment Reminder (24hr)".
|
||||||
|
|
||||||
|
Design requirements:
|
||||||
|
- Square icon, 512x512 pixels
|
||||||
|
- Flat design style with a slight gradient
|
||||||
|
- Primary color: Amber/Orange (#f59e0b)
|
||||||
|
- Icon should represent notifications, alerts, and reminders
|
||||||
|
- Could show a bell with a notification badge, clock, or alarm
|
||||||
|
- Clean, minimalist design suitable for a SaaS application
|
||||||
|
- White or light elements on the colored background
|
||||||
|
- Rounded corners (similar to modern app icons)
|
||||||
|
- Attention-grabbing but professional appearance
|
||||||
|
|
||||||
|
The icon represents a plugin that sends reminder emails to customers 24 hours before their appointments.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'inactive-customer-reengagement.png',
|
||||||
|
'prompt': '''Create a modern, professional icon/logo for a plugin called "Inactive Customer Re-engagement".
|
||||||
|
|
||||||
|
Design requirements:
|
||||||
|
- Square icon, 512x512 pixels
|
||||||
|
- Flat design style with a slight gradient
|
||||||
|
- Primary color: Purple (#8b5cf6)
|
||||||
|
- Icon should represent customer retention, returning customers, or re-engagement
|
||||||
|
- Could show a heart, person with return arrow, refresh symbol, or comeback concept
|
||||||
|
- Clean, minimalist design suitable for a SaaS application
|
||||||
|
- White or light elements on the colored background
|
||||||
|
- Rounded corners (similar to modern app icons)
|
||||||
|
- Warm and welcoming appearance
|
||||||
|
|
||||||
|
The icon represents a plugin that wins back customers who haven't booked appointments recently.'''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_image(prompt, filename):
|
||||||
|
"""Generate an image using Gemini API"""
|
||||||
|
print(f"\nGenerating {filename}...")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"contents": [{
|
||||||
|
"parts": [{
|
||||||
|
"text": prompt
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"generationConfig": {
|
||||||
|
"temperature": 1,
|
||||||
|
"topK": 40,
|
||||||
|
"topP": 0.95,
|
||||||
|
"maxOutputTokens": 8192,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{API_URL}?key={API_KEY}",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
# Check if there's an image in the response
|
||||||
|
if 'candidates' in result and len(result['candidates']) > 0:
|
||||||
|
candidate = result['candidates'][0]
|
||||||
|
if 'content' in candidate and 'parts' in candidate['content']:
|
||||||
|
for part in candidate['content']['parts']:
|
||||||
|
if 'inlineData' in part:
|
||||||
|
# Extract and save the image
|
||||||
|
image_data = base64.b64decode(part['inlineData']['data'])
|
||||||
|
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(image_data)
|
||||||
|
print(f"✓ Saved: {output_path}")
|
||||||
|
return True
|
||||||
|
elif 'text' in part:
|
||||||
|
print(f"Response: {part['text'][:200]}...")
|
||||||
|
|
||||||
|
print(f"✗ No image generated. Response: {result}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"✗ API Error ({response.status_code}): {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Create output directory
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print("Starting logo generation with Gemini API...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for plugin in plugins:
|
||||||
|
if generate_image(plugin['prompt'], plugin['filename']):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
112
frontend/public/plugin-logos/generate_with_gemini_sdk.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import google.generativeai as genai
|
||||||
|
import os
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Configure API Key
|
||||||
|
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||||
|
|
||||||
|
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||||
|
|
||||||
|
# Plugin configurations
|
||||||
|
plugins = [
|
||||||
|
{
|
||||||
|
'filename': 'daily-appointment-summary.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||||
|
Design: Indigo blue gradient background (#4f46e5). White simple envelope icon combined with a small calendar symbol.
|
||||||
|
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
|
||||||
|
The icon should be instantly recognizable at 48x48 pixels.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'no-show-tracker.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||||
|
Design: Red gradient background (#dc2626). White simple person silhouette with a bold X or cancel symbol overlay.
|
||||||
|
Style: Flat design, clean geometric shapes, professional SaaS application aesthetic.
|
||||||
|
The icon should clearly convey "missed appointment" at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'birthday-greetings.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||||
|
Design: Pink gradient background (#ec4899). White simple birthday cake with candles or gift box with bow.
|
||||||
|
Style: Flat design, clean geometric shapes, cheerful yet professional aesthetic.
|
||||||
|
The icon should feel celebratory and friendly at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'monthly-revenue-report.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||||
|
Design: Green gradient background (#10b981). White simple upward trending line chart or bar graph showing growth.
|
||||||
|
Style: Flat design, clean geometric shapes, professional business analytics aesthetic.
|
||||||
|
The icon should convey success and growth at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'appointment-reminder-24hr.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||||
|
Design: Amber/orange gradient background (#f59e0b). White simple notification bell with a small red alert dot or badge.
|
||||||
|
Style: Flat design, clean geometric shapes, attention-grabbing yet professional aesthetic.
|
||||||
|
The icon should clearly indicate alerts and reminders at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'inactive-customer-reengagement.png',
|
||||||
|
'prompt': '''Create a modern, minimalist app icon in a square format with rounded corners.
|
||||||
|
Design: Purple gradient background (#8b5cf6). White simple heart symbol with a circular refresh/return arrow around it.
|
||||||
|
Style: Flat design, clean geometric shapes, warm and welcoming professional aesthetic.
|
||||||
|
The icon should convey customer care and comeback at small sizes.'''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_image(prompt, filename):
|
||||||
|
"""Generate an image using Gemini 2.0 Flash Image Generation"""
|
||||||
|
print(f"\nGenerating {filename}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use the image generation model
|
||||||
|
model = genai.GenerativeModel('gemini-2.0-flash-exp-image-generation')
|
||||||
|
|
||||||
|
# Generate the image
|
||||||
|
response = model.generate_content(prompt)
|
||||||
|
|
||||||
|
# Check for generated image
|
||||||
|
if hasattr(response, 'parts') and response.parts:
|
||||||
|
for part in response.parts:
|
||||||
|
if hasattr(part, 'inline_data') and part.inline_data:
|
||||||
|
# Extract image data
|
||||||
|
image_data = part.inline_data.data
|
||||||
|
|
||||||
|
# Save the image
|
||||||
|
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(image_data)
|
||||||
|
|
||||||
|
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If no image data found, check if there's text response
|
||||||
|
if hasattr(response, 'text'):
|
||||||
|
print(f"✗ No image generated. Response text: {response.text[:200]}")
|
||||||
|
else:
|
||||||
|
print(f"✗ No image generated. Response: {response}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {type(e).__name__}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Create output directory
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print("Starting logo generation with Gemini 2.0 Flash Image Generation...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for plugin in plugins:
|
||||||
|
if generate_image(plugin['prompt'], plugin['filename']):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
146
frontend/public/plugin-logos/generate_with_imagen.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Imagen API configuration
|
||||||
|
API_KEY = "AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw"
|
||||||
|
MODEL = "imagen-4.0-generate-preview-06-06"
|
||||||
|
API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:predict"
|
||||||
|
|
||||||
|
OUTPUT_DIR = "/home/poduck/Desktop/smoothschedule2/frontend/public/plugin-logos"
|
||||||
|
|
||||||
|
# Plugin configurations
|
||||||
|
plugins = [
|
||||||
|
{
|
||||||
|
'filename': 'daily-appointment-summary.png',
|
||||||
|
'prompt': '''A modern, minimalist app icon for "Daily Appointment Summary Email" plugin.
|
||||||
|
Square format with rounded corners. Indigo blue gradient background (#4f46e5).
|
||||||
|
White icon showing a combination of an envelope and a calendar.
|
||||||
|
Flat design, clean lines, professional SaaS application style.
|
||||||
|
The icon should be simple and recognizable at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'no-show-tracker.png',
|
||||||
|
'prompt': '''A modern, minimalist app icon for "No-Show Customer Tracker" plugin.
|
||||||
|
Square format with rounded corners. Red gradient background (#dc2626).
|
||||||
|
White icon showing a person silhouette with an X or cancel symbol overlay.
|
||||||
|
Flat design, clean lines, professional SaaS application style.
|
||||||
|
The icon should convey missed appointments clearly at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'birthday-greetings.png',
|
||||||
|
'prompt': '''A modern, minimalist app icon for "Birthday Greeting Campaign" plugin.
|
||||||
|
Square format with rounded corners. Pink gradient background (#ec4899).
|
||||||
|
White icon showing a birthday cake or gift box with a bow.
|
||||||
|
Flat design, clean lines, cheerful yet professional style.
|
||||||
|
The icon should be celebratory and friendly at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'monthly-revenue-report.png',
|
||||||
|
'prompt': '''A modern, minimalist app icon for "Monthly Revenue Report" plugin.
|
||||||
|
Square format with rounded corners. Green gradient background (#10b981).
|
||||||
|
White icon showing an upward trending chart or bar graph.
|
||||||
|
Flat design, clean lines, professional business analytics style.
|
||||||
|
The icon should convey growth and success at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'appointment-reminder-24hr.png',
|
||||||
|
'prompt': '''A modern, minimalist app icon for "Appointment Reminder" plugin.
|
||||||
|
Square format with rounded corners. Amber/orange gradient background (#f59e0b).
|
||||||
|
White icon showing a notification bell with a small red badge or alert dot.
|
||||||
|
Flat design, clean lines, attention-grabbing yet professional style.
|
||||||
|
The icon should convey alerts and reminders clearly at small sizes.'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'filename': 'inactive-customer-reengagement.png',
|
||||||
|
'prompt': '''A modern, minimalist app icon for "Inactive Customer Re-engagement" plugin.
|
||||||
|
Square format with rounded corners. Purple gradient background (#8b5cf6).
|
||||||
|
White icon showing a heart with a circular refresh arrow around it.
|
||||||
|
Flat design, clean lines, warm and welcoming professional style.
|
||||||
|
The icon should convey customer care and return at small sizes.'''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_image(prompt, filename):
|
||||||
|
"""Generate an image using Imagen API"""
|
||||||
|
print(f"\nGenerating {filename}...")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"instances": [{
|
||||||
|
"prompt": prompt
|
||||||
|
}],
|
||||||
|
"parameters": {
|
||||||
|
"sampleCount": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{API_URL}?key={API_KEY}",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
# Check if there's an image in the predictions
|
||||||
|
if 'predictions' in result and len(result['predictions']) > 0:
|
||||||
|
prediction = result['predictions'][0]
|
||||||
|
if 'bytesBase64Encoded' in prediction:
|
||||||
|
# Extract and save the image
|
||||||
|
image_data = base64.b64decode(prediction['bytesBase64Encoded'])
|
||||||
|
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(image_data)
|
||||||
|
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||||
|
return True
|
||||||
|
elif 'image' in prediction and 'bytesBase64Encoded' in prediction['image']:
|
||||||
|
image_data = base64.b64decode(prediction['image']['bytesBase64Encoded'])
|
||||||
|
output_path = os.path.join(OUTPUT_DIR, filename)
|
||||||
|
with open(output_path, 'wb') as f:
|
||||||
|
f.write(image_data)
|
||||||
|
print(f"✓ Saved: {output_path} ({len(image_data)} bytes)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f"✗ No image generated. Response: {str(result)[:300]}...")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"✗ API Error ({response.status_code}): {response.text[:500]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Create output directory
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print("Starting logo generation with Imagen 4.0 API...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for i, plugin in enumerate(plugins):
|
||||||
|
if i > 0:
|
||||||
|
# Add delay between requests to avoid rate limiting
|
||||||
|
print("Waiting 2 seconds before next request...")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if generate_image(plugin['prompt'], plugin['filename']):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"Generation complete: {success_count}/{len(plugins)} successful")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
frontend/public/plugin-logos/inactive-customer-reengagement.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/public/plugin-logos/monthly-revenue-report.png
Normal file
|
After Width: | Height: | Size: 706 B |
BIN
frontend/public/plugin-logos/no-show-tracker.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
1
frontend/public/plugin-logos/send_email.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-mail"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
43
frontend/public/plugin-logos/test_gemini_image.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import google.generativeai as genai
|
||||||
|
import os
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Configure API Key
|
||||||
|
genai.configure(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||||
|
|
||||||
|
# Try to initialize the model
|
||||||
|
try:
|
||||||
|
generation_model = genai.GenerativeModel('gemini-pro-vision')
|
||||||
|
print("Model initialized successfully")
|
||||||
|
|
||||||
|
# Define prompt
|
||||||
|
prompt = "A modern app icon with a blue background and white envelope symbol"
|
||||||
|
print(f"Generating image for: '{prompt}'...")
|
||||||
|
|
||||||
|
# Try to generate
|
||||||
|
response = generation_model.generate_content(prompt)
|
||||||
|
|
||||||
|
print(f"Response type: {type(response)}")
|
||||||
|
print(f"Response: {response}")
|
||||||
|
|
||||||
|
if hasattr(response, 'parts') and response.parts:
|
||||||
|
print(f"Parts: {response.parts}")
|
||||||
|
if hasattr(response.parts[0], 'inline_data'):
|
||||||
|
print("Has inline_data!")
|
||||||
|
image_data = response.parts[0].inline_data.data
|
||||||
|
image = Image.open(io.BytesIO(image_data))
|
||||||
|
image.save("test_output.png")
|
||||||
|
print("Success! Image saved to test_output.png")
|
||||||
|
else:
|
||||||
|
print("No inline_data attribute")
|
||||||
|
print(f"Part attributes: {dir(response.parts[0])}")
|
||||||
|
else:
|
||||||
|
print("No parts in response or response.parts doesn't exist")
|
||||||
|
if hasattr(response, 'text'):
|
||||||
|
print(f"Response text: {response.text}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {type(e).__name__}: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
41
frontend/public/plugin-logos/test_gemini_native_image.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Setup Client
|
||||||
|
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||||
|
|
||||||
|
# Define the Prompt
|
||||||
|
prompt = "Create a simple modern app icon with a blue background and white envelope symbol, square format, flat design, minimalist"
|
||||||
|
|
||||||
|
print(f"Asking Gemini to generate: '{prompt}'...")
|
||||||
|
|
||||||
|
# Call the Gemini Model
|
||||||
|
try:
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model='gemini-2.5-flash-image',
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["IMAGE"] # Tell Gemini to draw, not talk
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the Image
|
||||||
|
if response.candidates[0].content.parts:
|
||||||
|
for part in response.candidates[0].content.parts:
|
||||||
|
if part.inline_data:
|
||||||
|
image_data = part.inline_data.data
|
||||||
|
image = Image.open(BytesIO(image_data))
|
||||||
|
image.save("test_gemini_native.png")
|
||||||
|
print("Success! Saved test_gemini_native.png")
|
||||||
|
else:
|
||||||
|
print("Model returned text instead of image:", part.text)
|
||||||
|
else:
|
||||||
|
print("No content parts in response")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {type(e).__name__}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
41
frontend/public/plugin-logos/test_google_genai.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
from io import BytesIO
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Initialize the Client
|
||||||
|
client = genai.Client(api_key="AIzaSyB-nR0nkeftKrd42NrNIDcFCj3yFP8JLtw")
|
||||||
|
|
||||||
|
# Define the prompt
|
||||||
|
prompt = "A simple modern app icon with a blue background and white envelope symbol, square format, flat design"
|
||||||
|
|
||||||
|
print(f"Generating image for: '{prompt}'...")
|
||||||
|
|
||||||
|
# Call the API
|
||||||
|
try:
|
||||||
|
response = client.models.generate_images(
|
||||||
|
model='gemini-2.5-flash-image',
|
||||||
|
prompt=prompt,
|
||||||
|
config=types.GenerateImagesConfig(
|
||||||
|
number_of_images=1,
|
||||||
|
aspect_ratio="1:1",
|
||||||
|
safety_filter_level="BLOCK_LOW_AND_ABOVE",
|
||||||
|
person_generation="ALLOW_ADULT"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle the response
|
||||||
|
for i, generated_image in enumerate(response.generated_images):
|
||||||
|
# Convert raw bytes to an image
|
||||||
|
image = Image.open(BytesIO(generated_image.image.image_bytes))
|
||||||
|
|
||||||
|
# Save to disk
|
||||||
|
filename = f"test_generated_image_{i}.png"
|
||||||
|
image.save(filename)
|
||||||
|
print(f"Success! Saved {filename}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error occurred: {type(e).__name__}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
1
frontend/public/plugin-logos/webhook.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||||
|
After Width: | Height: | Size: 349 B |
@@ -65,6 +65,7 @@ import HelpPluginDocs from './pages/HelpPluginDocs'; // Import Plugin documentat
|
|||||||
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
|
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||||
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
|
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
|
||||||
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
|
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
|
||||||
|
import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions
|
||||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -536,6 +537,16 @@ const AppContent: React.FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tasks"
|
||||||
|
element={
|
||||||
|
hasAccess(['owner', 'manager']) ? (
|
||||||
|
<Tasks />
|
||||||
|
) : (
|
||||||
|
<Navigate to="/" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/support" element={<PlatformSupport />} />
|
<Route path="/support" element={<PlatformSupport />} />
|
||||||
<Route
|
<Route
|
||||||
path="/customers"
|
path="/customers"
|
||||||
|
|||||||
@@ -765,6 +765,20 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
|||||||
<code className="text-purple-600 dark:text-purple-400">{'{{PROMPT:variable|description|default}}'}</code>
|
<code className="text-purple-600 dark:text-purple-400">{'{{PROMPT:variable|description|default}}'}</code>
|
||||||
<span className="text-gray-500 ml-2">- Optional field with default value</span>
|
<span className="text-gray-500 ml-2">- Optional field with default value</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="text-purple-600 dark:text-purple-400">{'{{PROMPT:variable|description|default|textarea}}'}</code>
|
||||||
|
<span className="text-gray-500 ml-2">- Multi-line text input (for email bodies, long messages)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200 font-semibold mb-1">
|
||||||
|
Field Type Detection
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
The system automatically detects field types from variable names and descriptions: <code>email</code> for email validation,
|
||||||
|
<code> number</code> for numeric inputs, <code>message/body/content</code> for textareas, <code>url/webhook</code> for URLs.
|
||||||
|
You can override this by explicitly specifying the type as the 4th parameter.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
@@ -813,9 +827,65 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
|||||||
<div className="text-blue-600 dark:text-blue-400">{'{{DATE:friday}}'} - Next Friday</div>
|
<div className="text-blue-600 dark:text-blue-400">{'{{DATE:friday}}'} - Next Friday</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. Validation & Types */}
|
{/* 4. Insertion Codes */}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6">
|
||||||
4. Automatic Validation
|
4. Insertion Codes (Dynamic Content)
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||||
|
Use insertion codes within your PROMPT template text (like email bodies) to inject dynamic content
|
||||||
|
at runtime. These are automatically replaced with actual values when the plugin executes.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
|
||||||
|
<p className="text-sm text-green-800 dark:text-green-200 mb-2">
|
||||||
|
<strong>Business Information:</strong>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1 text-xs text-green-700 dark:text-green-300 pl-3">
|
||||||
|
<div><code>{'{{BUSINESS_NAME}}'}</code> - Your business name</div>
|
||||||
|
<div><code>{'{{BUSINESS_EMAIL}}'}</code> - Business contact email</div>
|
||||||
|
<div><code>{'{{BUSINESS_PHONE}}'}</code> - Business phone number</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
||||||
|
<strong>Customer & Appointment Data:</strong>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1 text-xs text-blue-700 dark:text-blue-300 pl-3">
|
||||||
|
<div><code>{'{{CUSTOMER_NAME}}'}</code> - Customer's name (in appointment contexts)</div>
|
||||||
|
<div><code>{'{{CUSTOMER_EMAIL}}'}</code> - Customer's email address</div>
|
||||||
|
<div><code>{'{{APPOINTMENT_TIME}}'}</code> - Full appointment date and time</div>
|
||||||
|
<div><code>{'{{APPOINTMENT_DATE}}'}</code> - Appointment date only</div>
|
||||||
|
<div><code>{'{{APPOINTMENT_SERVICE}}'}</code> - Service name</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 p-3 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-800">
|
||||||
|
<p className="text-sm text-purple-800 dark:text-purple-200 mb-2">
|
||||||
|
<strong>Date & Time:</strong>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1 text-xs text-purple-700 dark:text-purple-300 pl-3">
|
||||||
|
<div><code>{'{{TODAY}}'}</code> - Today's date (YYYY-MM-DD)</div>
|
||||||
|
<div><code>{'{{NOW}}'}</code> - Current date and time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200 font-semibold mb-2">Example: Email Template with Insertion Codes</p>
|
||||||
|
<code className="text-xs block bg-white dark:bg-gray-900 p-3 rounded font-mono text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
|
||||||
|
{`email_body = '{{PROMPT:email_body|Email Message|Hi {{CUSTOMER_NAME}},
|
||||||
|
|
||||||
|
This is a reminder about your appointment:
|
||||||
|
|
||||||
|
Date/Time: {{APPOINTMENT_TIME}}
|
||||||
|
Service: {{APPOINTMENT_SERVICE}}
|
||||||
|
|
||||||
|
If you have questions, contact us at {{BUSINESS_EMAIL}}
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{{BUSINESS_NAME}}||textarea}}'`}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. Validation & Types */}
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6">
|
||||||
|
5. Automatic Validation
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||||
The system automatically detects field types and validates input:
|
The system automatically detects field types and validates input:
|
||||||
@@ -830,7 +900,8 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
|||||||
<div className="mt-6 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
<div className="mt-6 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||||
<p className="text-sm text-purple-800 dark:text-purple-200">
|
<p className="text-sm text-purple-800 dark:text-purple-200">
|
||||||
<strong>Pro Tip:</strong> Combine all template types for maximum power! Use CONTEXT
|
<strong>Pro Tip:</strong> Combine all template types for maximum power! Use CONTEXT
|
||||||
for business info, DATE for time logic, and PROMPT only when user input is truly needed.
|
for business info, DATE for time logic, PROMPT for user configuration, and Insertion Codes
|
||||||
|
within your email templates for personalized dynamic content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</ApiContent>
|
</ApiContent>
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ const MyPlugins: React.FC = () => {
|
|||||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
|
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
|
||||||
const [showUninstallModal, setShowUninstallModal] = useState(false);
|
const [showUninstallModal, setShowUninstallModal] = useState(false);
|
||||||
const [showRatingModal, setShowRatingModal] = useState(false);
|
const [showRatingModal, setShowRatingModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
const [review, setReview] = useState('');
|
const [review, setReview] = useState('');
|
||||||
|
const [configValues, setConfigValues] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// Fetch installed plugins
|
// Fetch installed plugins
|
||||||
const { data: plugins = [], isLoading, error } = useQuery<PluginInstallation[]>({
|
const { data: plugins = [], isLoading, error } = useQuery<PluginInstallation[]>({
|
||||||
@@ -67,6 +69,10 @@ const MyPlugins: React.FC = () => {
|
|||||||
templateDescription: p.template_description || p.templateDescription,
|
templateDescription: p.template_description || p.templateDescription,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
version: p.version,
|
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,
|
isActive: p.is_active !== undefined ? p.is_active : p.isActive,
|
||||||
installedAt: p.installed_at || p.installedAt,
|
installedAt: p.installed_at || p.installedAt,
|
||||||
hasUpdate: p.has_update !== undefined ? p.has_update : p.hasUpdate || false,
|
hasUpdate: p.has_update !== undefined ? p.has_update : p.hasUpdate || false,
|
||||||
@@ -117,6 +123,22 @@ const MyPlugins: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Edit config mutation
|
||||||
|
const editConfigMutation = useMutation({
|
||||||
|
mutationFn: async ({ pluginId, configValues }: { pluginId: string; configValues: Record<string, any> }) => {
|
||||||
|
const { data } = await api.patch(`/api/plugin-installations/${pluginId}/`, {
|
||||||
|
config_values: configValues,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||||
|
setShowEditModal(false);
|
||||||
|
setSelectedPlugin(null);
|
||||||
|
setConfigValues({});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleUninstall = (plugin: PluginInstallation) => {
|
const handleUninstall = (plugin: PluginInstallation) => {
|
||||||
setSelectedPlugin(plugin);
|
setSelectedPlugin(plugin);
|
||||||
setShowUninstallModal(true);
|
setShowUninstallModal(true);
|
||||||
@@ -149,6 +171,59 @@ const MyPlugins: React.FC = () => {
|
|||||||
updateMutation.mutate(plugin.id);
|
updateMutation.mutate(plugin.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unescapeString = (str: string): string => {
|
||||||
|
return str
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\r/g, '\r')
|
||||||
|
.replace(/\\t/g, '\t')
|
||||||
|
.replace(/\\'/g, "'")
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
|
.replace(/\\\\/g, '\\');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (plugin: PluginInstallation) => {
|
||||||
|
setSelectedPlugin(plugin);
|
||||||
|
// Convert escape sequences to actual characters for display
|
||||||
|
const displayValues = { ...plugin.configValues || {} };
|
||||||
|
if (plugin.templateVariables) {
|
||||||
|
Object.entries(plugin.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||||
|
if (displayValues[key]) {
|
||||||
|
displayValues[key] = unescapeString(displayValues[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setConfigValues(displayValues);
|
||||||
|
setShowEditModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeString = (str: string): string => {
|
||||||
|
return str
|
||||||
|
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\t/g, '\\t')
|
||||||
|
.replace(/'/g, "\\'")
|
||||||
|
.replace(/"/g, '\\"');
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfigEdit = () => {
|
||||||
|
if (selectedPlugin) {
|
||||||
|
// 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 (storageValues[key]) {
|
||||||
|
storageValues[key] = escapeString(storageValues[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
editConfigMutation.mutate({
|
||||||
|
pluginId: selectedPlugin.id,
|
||||||
|
configValues: storageValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
@@ -213,13 +288,25 @@ const MyPlugins: React.FC = () => {
|
|||||||
{plugins.map((plugin) => (
|
{plugins.map((plugin) => (
|
||||||
<div
|
<div
|
||||||
key={plugin.id}
|
key={plugin.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"
|
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 cursor-pointer"
|
||||||
|
onClick={() => handleEdit(plugin)}
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
{/* Plugin Info */}
|
{/* Plugin Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{plugin.logoUrl && (
|
||||||
|
<img
|
||||||
|
src={plugin.logoUrl}
|
||||||
|
alt={`${plugin.templateName} logo`}
|
||||||
|
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||||
|
onError={(e) => {
|
||||||
|
// Hide image if it fails to load
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{plugin.templateName}
|
{plugin.templateName}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -240,6 +327,14 @@ const MyPlugins: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{plugin.authorName && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t('plugins.author', 'Author')}:
|
||||||
|
</span>
|
||||||
|
<span>{plugin.authorName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{t('plugins.version', 'Version')}:
|
{t('plugins.version', 'Version')}:
|
||||||
@@ -284,7 +379,10 @@ const MyPlugins: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
{plugin.hasUpdate && (
|
{plugin.hasUpdate && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdate(plugin)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleUpdate(plugin);
|
||||||
|
}}
|
||||||
disabled={updateMutation.isPending}
|
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"
|
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('plugins.update', 'Update')}
|
||||||
@@ -298,7 +396,10 @@ const MyPlugins: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRating(plugin)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRating(plugin);
|
||||||
|
}}
|
||||||
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"
|
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={plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||||
>
|
>
|
||||||
@@ -306,7 +407,10 @@ const MyPlugins: React.FC = () => {
|
|||||||
{plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
{plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUninstall(plugin)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleUninstall(plugin);
|
||||||
|
}}
|
||||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
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('plugins.uninstall', 'Uninstall')}
|
||||||
>
|
>
|
||||||
@@ -519,6 +623,115 @@ const MyPlugins: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Config Modal */}
|
||||||
|
{showEditModal && selectedPlugin && (
|
||||||
|
<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')}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditModal(false);
|
||||||
|
setSelectedPlugin(null);
|
||||||
|
setConfigValues({});
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<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}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{selectedPlugin.templateDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config Fields */}
|
||||||
|
{selectedPlugin.templateVariables && Object.keys(selectedPlugin.templateVariables).length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(selectedPlugin.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}
|
||||||
|
{variable.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{variable.type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
value={configValues[key] !== undefined ? configValues[key] : (variable.default ? unescapeString(variable.default) : '')}
|
||||||
|
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||||
|
rows={6}
|
||||||
|
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 font-mono text-sm"
|
||||||
|
placeholder={variable.default ? unescapeString(variable.default) : ''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={configValues[key] || variable.default || ''}
|
||||||
|
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||||
|
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"
|
||||||
|
placeholder={variable.default || ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variable.help_text && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{variable.help_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
{t('plugins.noConfigOptions', 'This plugin has no configuration options')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditModal(false);
|
||||||
|
setSelectedPlugin(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"
|
||||||
|
>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submitConfigEdit}
|
||||||
|
disabled={editConfigMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{editConfigMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
{t('plugins.saving', 'Saving...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
{t('common.save', 'Save')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
@@ -17,8 +17,12 @@ import {
|
|||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Bot,
|
Bot,
|
||||||
Package,
|
Package,
|
||||||
Eye
|
Eye,
|
||||||
|
ChevronDown,
|
||||||
|
AlertTriangle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import { PluginTemplate, PluginCategory } from '../types';
|
import { PluginTemplate, PluginCategory } from '../types';
|
||||||
|
|
||||||
@@ -44,6 +48,32 @@ const categoryColors: Record<PluginCategory, string> = {
|
|||||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to detect platform-only code
|
||||||
|
const detectPlatformOnlyCode = (code: string): { hasPlatformCode: boolean; warnings: string[] } => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (code.includes('{{PLATFORM:')) {
|
||||||
|
warnings.push('This code uses platform-only features that require special permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.includes('{{WHITELIST:')) {
|
||||||
|
warnings.push('This code requires whitelisting by platform administrators');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.includes('{{SUPERUSER:')) {
|
||||||
|
warnings.push('This code requires superuser privileges to execute');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.includes('{{SYSTEM:')) {
|
||||||
|
warnings.push('This code accesses system-level features');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasPlatformCode: warnings.length > 0,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const PluginMarketplace: React.FC = () => {
|
const PluginMarketplace: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -51,6 +81,8 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
|
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
|
||||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginTemplate | null>(null);
|
const [selectedPlugin, setSelectedPlugin] = useState<PluginTemplate | null>(null);
|
||||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||||
|
const [showCode, setShowCode] = useState(false);
|
||||||
|
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||||
|
|
||||||
// Fetch marketplace plugins
|
// Fetch marketplace plugins
|
||||||
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
|
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
|
||||||
@@ -71,6 +103,8 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
isFeatured: p.is_featured || false,
|
isFeatured: p.is_featured || false,
|
||||||
createdAt: p.created_at,
|
createdAt: p.created_at,
|
||||||
updatedAt: p.updated_at,
|
updatedAt: p.updated_at,
|
||||||
|
logoUrl: p.logo_url,
|
||||||
|
pluginCode: p.plugin_code,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -122,9 +156,25 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [filteredPlugins]);
|
}, [filteredPlugins]);
|
||||||
|
|
||||||
const handleInstall = (plugin: PluginTemplate) => {
|
const handleInstall = async (plugin: PluginTemplate) => {
|
||||||
setSelectedPlugin(plugin);
|
setSelectedPlugin(plugin);
|
||||||
setShowDetailsModal(true);
|
setShowDetailsModal(true);
|
||||||
|
setShowCode(false);
|
||||||
|
|
||||||
|
// Fetch full plugin details including plugin_code
|
||||||
|
setIsLoadingDetails(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/plugin-templates/${plugin.id}/`);
|
||||||
|
setSelectedPlugin({
|
||||||
|
...plugin,
|
||||||
|
pluginCode: data.plugin_code,
|
||||||
|
logoUrl: data.logo_url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plugin details:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingDetails(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmInstall = () => {
|
const confirmInstall = () => {
|
||||||
@@ -245,22 +295,39 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{/* Plugin Card Header */}
|
{/* Plugin Card Header */}
|
||||||
<div className="p-6 flex-1">
|
<div className="p-6 flex-1">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<div className="flex items-center gap-2">
|
{/* Logo */}
|
||||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
|
{plugin.logoUrl ? (
|
||||||
{categoryIcons[plugin.category]}
|
<img
|
||||||
{plugin.category}
|
src={plugin.logoUrl}
|
||||||
</span>
|
alt={`${plugin.name} logo`}
|
||||||
{plugin.isFeatured && (
|
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||||
<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
|
<div className="w-12 h-12 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||||
</span>
|
<Package className="h-6 w-6 text-gray-400" />
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
{plugin.isVerified && (
|
|
||||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{plugin.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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{plugin.isVerified && (
|
||||||
|
<CheckCircle className="h-5 w-5 text-blue-500 flex-shrink-0" title="Verified" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
@@ -292,10 +359,7 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
{/* Plugin Card Actions */}
|
{/* Plugin 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">
|
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => handleInstall(plugin)}
|
||||||
setSelectedPlugin(plugin);
|
|
||||||
setShowDetailsModal(true);
|
|
||||||
}}
|
|
||||||
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"
|
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" />
|
<Eye className="h-4 w-4" />
|
||||||
@@ -314,17 +378,32 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
{/* Logo in modal header */}
|
||||||
{selectedPlugin.name}
|
{selectedPlugin.logoUrl ? (
|
||||||
</h3>
|
<img
|
||||||
{selectedPlugin.isVerified && (
|
src={selectedPlugin.logoUrl}
|
||||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
alt={`${selectedPlugin.name} logo`}
|
||||||
|
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Package className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{selectedPlugin.name}
|
||||||
|
</h3>
|
||||||
|
{selectedPlugin.isVerified && (
|
||||||
|
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDetailsModal(false);
|
setShowDetailsModal(false);
|
||||||
setSelectedPlugin(null);
|
setSelectedPlugin(null);
|
||||||
|
setShowCode(false);
|
||||||
}}
|
}}
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -334,60 +413,131 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
|
|
||||||
{/* Modal Body */}
|
{/* Modal Body */}
|
||||||
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||||
<div className="flex items-center gap-2">
|
{isLoadingDetails ? (
|
||||||
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
|
<div className="flex items-center justify-center py-12">
|
||||||
{categoryIcons[selectedPlugin.category]}
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||||
{selectedPlugin.category}
|
</div>
|
||||||
</span>
|
) : (
|
||||||
{selectedPlugin.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">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="h-4 w-4" />
|
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
|
||||||
Featured
|
{categoryIcons[selectedPlugin.category]}
|
||||||
</span>
|
{selectedPlugin.category}
|
||||||
)}
|
</span>
|
||||||
</div>
|
{selectedPlugin.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
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
{t('plugins.description', 'Description')}
|
{t('plugins.description', 'Description')}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
{selectedPlugin.description}
|
{selectedPlugin.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
{t('plugins.version', 'Version')}
|
{t('plugins.version', 'Version')}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
|
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
{t('plugins.author', 'Author')}
|
{t('plugins.author', 'Author')}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
|
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 text-sm">
|
<div className="flex items-center gap-6 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">
|
<span className="font-semibold text-gray-900 dark:text-white">
|
||||||
{selectedPlugin.rating.toFixed(1)}
|
{selectedPlugin.rating.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
|
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="h-5 w-5 text-gray-400" />
|
<Download className="h-5 w-5 text-gray-400" />
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
<span className="text-gray-600 dark:text-gray-400">
|
||||||
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
|
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Code Viewer Section */}
|
||||||
|
{selectedPlugin.pluginCode && (
|
||||||
|
<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')}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-5 w-5 text-gray-600 dark:text-gray-400 transition-transform ${showCode ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCode && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Platform-only code warnings */}
|
||||||
|
{(() => {
|
||||||
|
const { hasPlatformCode, warnings } = detectPlatformOnlyCode(selectedPlugin.pluginCode || '');
|
||||||
|
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')}
|
||||||
|
</h5>
|
||||||
|
<ul className="space-y-1 text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
{warnings.map((warning, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-2">
|
||||||
|
<span className="text-amber-600 dark:text-amber-400 mt-0.5">•</span>
|
||||||
|
<span>{warning}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Code display with syntax highlighting */}
|
||||||
|
<div className="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="javascript"
|
||||||
|
style={vscDarkPlus}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: 0,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
maxHeight: '400px'
|
||||||
|
}}
|
||||||
|
showLineNumbers
|
||||||
|
>
|
||||||
|
{selectedPlugin.pluginCode}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
@@ -396,6 +546,7 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDetailsModal(false);
|
setShowDetailsModal(false);
|
||||||
setSelectedPlugin(null);
|
setSelectedPlugin(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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -403,7 +554,7 @@ const PluginMarketplace: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={confirmInstall}
|
onClick={confirmInstall}
|
||||||
disabled={installMutation.isPending}
|
disabled={installMutation.isPending || isLoadingDetails}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
>
|
>
|
||||||
{installMutation.isPending ? (
|
{installMutation.isPending ? (
|
||||||
|
|||||||
365
frontend/src/pages/Tasks.tsx
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import axios from '../api/client';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
RotateCw,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface ScheduledTask {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
plugin_name: string;
|
||||||
|
plugin_display_name: string;
|
||||||
|
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
|
||||||
|
cron_expression?: string;
|
||||||
|
interval_minutes?: number;
|
||||||
|
run_at?: string;
|
||||||
|
next_run_time?: string;
|
||||||
|
last_run_time?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
plugin_config: Record<string, any>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tasks: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
|
||||||
|
// Fetch tasks
|
||||||
|
const { data: tasks = [], isLoading } = useQuery<ScheduledTask[]>({
|
||||||
|
queryKey: ['scheduled-tasks'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await axios.get('/api/scheduled-tasks/');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete task
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (taskId: string) => {
|
||||||
|
await axios.delete(`/api/scheduled-tasks/${taskId}/`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
|
||||||
|
toast.success('Task deleted successfully');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to delete task');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle task active status
|
||||||
|
const toggleActiveMutation = useMutation({
|
||||||
|
mutationFn: async ({ taskId, isActive }: { taskId: string; isActive: boolean }) => {
|
||||||
|
await axios.patch(`/api/scheduled-tasks/${taskId}/`, { is_active: isActive });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
|
||||||
|
toast.success('Task updated successfully');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to update task');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger task manually
|
||||||
|
const triggerMutation = useMutation({
|
||||||
|
mutationFn: async (taskId: string) => {
|
||||||
|
await axios.post(`/api/scheduled-tasks/${taskId}/trigger/`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Task triggered successfully');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to trigger task');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getScheduleDisplay = (task: ScheduledTask): string => {
|
||||||
|
if (task.schedule_type === 'ONE_TIME') {
|
||||||
|
return `Once on ${new Date(task.run_at!).toLocaleString()}`;
|
||||||
|
} else if (task.schedule_type === 'INTERVAL') {
|
||||||
|
const hours = Math.floor(task.interval_minutes! / 60);
|
||||||
|
const mins = task.interval_minutes! % 60;
|
||||||
|
if (hours > 0 && mins > 0) {
|
||||||
|
return `Every ${hours}h ${mins}m`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `Every ${hours} hour${hours > 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
return `Every ${mins} minute${mins > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return task.cron_expression || 'Custom schedule';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScheduleIcon = (task: ScheduledTask) => {
|
||||||
|
if (task.schedule_type === 'ONE_TIME') return Calendar;
|
||||||
|
if (task.schedule_type === 'INTERVAL') return RotateCw;
|
||||||
|
return Clock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (task: ScheduledTask): string => {
|
||||||
|
if (!task.is_active) return 'text-gray-400 dark:text-gray-500';
|
||||||
|
if (task.last_run_time) return 'text-green-600 dark:text-green-400';
|
||||||
|
return 'text-blue-600 dark:text-blue-400';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (task: ScheduledTask) => {
|
||||||
|
if (!task.is_active) return Pause;
|
||||||
|
if (task.last_run_time) return CheckCircle2;
|
||||||
|
return AlertCircle;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('Tasks')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Schedule and manage automated plugin executions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
New Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||||
|
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Total Tasks</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">{tasks.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Active</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{tasks.filter(t => t.is_active).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||||
|
<Pause className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Paused</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{tasks.filter(t => !t.is_active).length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||||
|
<RotateCw className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Recurring</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{tasks.filter(t => t.schedule_type !== 'ONE_TIME').length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks List */}
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<Zap className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
No tasks yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Create your first automated task to get started
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Create Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const ScheduleIcon = getScheduleIcon(task);
|
||||||
|
const StatusIcon = getStatusIcon(task);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{task.name}
|
||||||
|
</h3>
|
||||||
|
<StatusIcon className={`w-5 h-5 ${getStatusColor(task)}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||||
|
<ScheduleIcon className="w-4 h-4" />
|
||||||
|
<span>{getScheduleDisplay(task)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.next_run_time && task.is_active && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Next: {new Date(task.next_run_time).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.last_run_time && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span>Last: {new Date(task.last_run_time).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => triggerMutation.mutate(task.id)}
|
||||||
|
className="p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||||
|
title="Run now"
|
||||||
|
>
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActiveMutation.mutate({
|
||||||
|
taskId: task.id,
|
||||||
|
isActive: !task.is_active
|
||||||
|
})}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
task.is_active
|
||||||
|
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
|
||||||
|
: 'text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||||
|
}`}
|
||||||
|
title={task.is_active ? 'Pause' : 'Resume'}
|
||||||
|
>
|
||||||
|
{task.is_active ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {/* TODO: Edit modal */}}
|
||||||
|
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Are you sure you want to delete this task?')) {
|
||||||
|
deleteMutation.mutate(task.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TODO: Create/Edit Task Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<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-lg max-w-2xl w-full p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Create New Task
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Task creation form coming soon...
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tasks;
|
||||||
@@ -314,6 +314,8 @@ export interface PluginTemplate {
|
|||||||
category: PluginCategory;
|
category: PluginCategory;
|
||||||
version: string;
|
version: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
pluginCode?: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
ratingCount: number;
|
ratingCount: number;
|
||||||
installCount: number;
|
installCount: number;
|
||||||
@@ -330,6 +332,10 @@ export interface PluginInstallation {
|
|||||||
templateDescription: string;
|
templateDescription: string;
|
||||||
category: PluginCategory;
|
category: PluginCategory;
|
||||||
version: string;
|
version: string;
|
||||||
|
authorName?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
templateVariables?: Record<string, any>;
|
||||||
|
configValues?: Record<string, any>;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
|
|||||||
@@ -23,7 +23,35 @@ This plugin sends a comprehensive email digest every morning with:
|
|||||||
- Any special notes or requirements
|
- Any special notes or requirements
|
||||||
|
|
||||||
Perfect for managers and staff who want to start their day informed and prepared.''',
|
Perfect for managers and staff who want to start their day informed and prepared.''',
|
||||||
'plugin_code': 'Get today\'s appointments, format list, send email to {{PROMPT:staff_email|Staff email}}',
|
'plugin_code': '''from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Get today's appointments
|
||||||
|
today = datetime.now().date()
|
||||||
|
appointments = api.get_appointments(
|
||||||
|
start_date=today.isoformat(),
|
||||||
|
end_date=today.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format the appointment list
|
||||||
|
summary = f"Daily Appointment Summary - {today}\\n\\n"
|
||||||
|
summary += f"Total Appointments: {len(appointments)}\\n\\n"
|
||||||
|
|
||||||
|
for apt in appointments:
|
||||||
|
summary += f"- {apt['title']} at {apt['start_time']}\\n"
|
||||||
|
summary += f" Status: {apt['status']}\\n\\n"
|
||||||
|
|
||||||
|
# Get customizable email settings
|
||||||
|
staff_email = '{{PROMPT:staff_email|Staff Email}}'
|
||||||
|
email_subject = '{{PROMPT:email_subject|Email Subject|Daily Appointment Summary - {{TODAY}}}}'
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
api.send_email(
|
||||||
|
to=staff_email,
|
||||||
|
subject=email_subject,
|
||||||
|
body=summary
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
'logo_url': '/plugin-logos/daily-appointment-summary.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'No-Show Customer Tracker',
|
'name': 'No-Show Customer Tracker',
|
||||||
@@ -39,7 +67,48 @@ This plugin automatically tracks and reports on:
|
|||||||
- Trends over time
|
- Trends over time
|
||||||
|
|
||||||
Helps you identify customers who may need reminder calls or deposits, improving your booking efficiency and revenue.''',
|
Helps you identify customers who may need reminder calls or deposits, improving your booking efficiency and revenue.''',
|
||||||
'plugin_code': 'Get no-shows from last week, group by customer, send report to {{PROMPT:manager_email|Manager email}}',
|
'plugin_code': '''from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Get configuration
|
||||||
|
days_back = int('{{PROMPT:days_back|Days to Look Back|7}}')
|
||||||
|
week_ago = (datetime.now() - timedelta(days=days_back)).date()
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
# Get appointments with NOSHOW status
|
||||||
|
appointments = api.get_appointments(
|
||||||
|
start_date=week_ago.isoformat(),
|
||||||
|
end_date=today.isoformat(),
|
||||||
|
status='NOSHOW'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count no-shows per customer
|
||||||
|
customer_noshows = {}
|
||||||
|
for apt in appointments:
|
||||||
|
customer_id = apt.get('customer_id')
|
||||||
|
if customer_id:
|
||||||
|
customer_noshows[customer_id] = customer_noshows.get(customer_id, 0) + 1
|
||||||
|
|
||||||
|
# Generate report
|
||||||
|
report = f"No-Show Report ({week_ago} to {today})\\n\\n"
|
||||||
|
report += f"Total No-Shows: {len(appointments)}\\n"
|
||||||
|
report += f"Unique Customers: {len(customer_noshows)}\\n\\n"
|
||||||
|
report += "Top Offenders:\\n"
|
||||||
|
|
||||||
|
for customer_id, count in sorted(customer_noshows.items(), key=lambda x: x[1], reverse=True)[:10]:
|
||||||
|
report += f"- Customer {customer_id}: {count} no-shows\\n"
|
||||||
|
|
||||||
|
# Get customizable email settings
|
||||||
|
manager_email = '{{PROMPT:manager_email|Manager Email}}'
|
||||||
|
email_subject = '{{PROMPT:email_subject|Email Subject|No-Show Report}}'
|
||||||
|
|
||||||
|
# Send report
|
||||||
|
api.send_email(
|
||||||
|
to=manager_email,
|
||||||
|
subject=email_subject,
|
||||||
|
body=report
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
'logo_url': '/plugin-logos/no-show-tracker.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Birthday Greeting Campaign',
|
'name': 'Birthday Greeting Campaign',
|
||||||
@@ -55,7 +124,29 @@ This plugin automatically:
|
|||||||
- Helps drive repeat bookings and customer loyalty
|
- Helps drive repeat bookings and customer loyalty
|
||||||
|
|
||||||
A simple way to show customers you care while encouraging them to book their next appointment.''',
|
A simple way to show customers you care while encouraging them to book their next appointment.''',
|
||||||
'plugin_code': 'Check for birthdays today, send personalized emails with {{PROMPT:discount_code|Discount code}}',
|
'plugin_code': '''# Get all customers with email addresses
|
||||||
|
customers = api.get_customers(has_email=True, limit=1000)
|
||||||
|
|
||||||
|
# Get customizable email template
|
||||||
|
discount_code = '{{PROMPT:discount_code|Discount Code}}'
|
||||||
|
email_subject = '{{PROMPT:email_subject|Email Subject|Happy Birthday!}}'
|
||||||
|
email_body = '{{PROMPT:email_body|Email Message|Happy Birthday {{CUSTOMER_NAME}}!\n\nWe hope you have a wonderful day! As a special birthday gift, we\'d like to offer you {discount_code} on your next appointment.\n\nBook now and treat yourself!\n\nBest wishes,\n{{BUSINESS_NAME}}||textarea}}'
|
||||||
|
|
||||||
|
# Filter for birthdays today (would need birthday field in customer data)
|
||||||
|
# For now, send to all customers as example
|
||||||
|
for customer in customers:
|
||||||
|
# Format email body with discount code
|
||||||
|
formatted_body = email_body.format(discount_code=discount_code)
|
||||||
|
|
||||||
|
api.send_email(
|
||||||
|
to=customer['email'],
|
||||||
|
subject=email_subject,
|
||||||
|
body=formatted_body
|
||||||
|
)
|
||||||
|
|
||||||
|
api.log(f"Sent {len(customers)} birthday greetings")
|
||||||
|
''',
|
||||||
|
'logo_url': '/plugin-logos/birthday-greetings.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Monthly Revenue Report',
|
'name': 'Monthly Revenue Report',
|
||||||
@@ -73,7 +164,55 @@ This plugin generates detailed reports including:
|
|||||||
- Year-over-year comparisons
|
- Year-over-year comparisons
|
||||||
|
|
||||||
Perfect for owners and managers who want to track business growth and identify opportunities.''',
|
Perfect for owners and managers who want to track business growth and identify opportunities.''',
|
||||||
'plugin_code': 'Get last month\'s appointments, calculate stats, email to {{PROMPT:owner_email|Owner email}}',
|
'plugin_code': '''from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Get last month's date range
|
||||||
|
today = datetime.now()
|
||||||
|
first_of_this_month = today.replace(day=1)
|
||||||
|
last_month_end = first_of_this_month - timedelta(days=1)
|
||||||
|
last_month_start = last_month_end.replace(day=1)
|
||||||
|
|
||||||
|
# Get all appointments from last month
|
||||||
|
appointments = api.get_appointments(
|
||||||
|
start_date=last_month_start.isoformat(),
|
||||||
|
end_date=last_month_end.isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate statistics
|
||||||
|
total_appointments = len(appointments)
|
||||||
|
completed = len([a for a in appointments if a['status'] == 'COMPLETED'])
|
||||||
|
canceled = len([a for a in appointments if a['status'] == 'CANCELED'])
|
||||||
|
|
||||||
|
# Generate report
|
||||||
|
month_name = last_month_start.strftime('%B %Y')
|
||||||
|
report = f"""Monthly Revenue Report - {month_name}
|
||||||
|
|
||||||
|
SUMMARY
|
||||||
|
-------
|
||||||
|
Total Appointments: {total_appointments}
|
||||||
|
Completed: {completed}
|
||||||
|
Canceled: {canceled}
|
||||||
|
Completion Rate: {(completed/total_appointments*100):.1f}%
|
||||||
|
|
||||||
|
DETAILS
|
||||||
|
-------
|
||||||
|
"""
|
||||||
|
|
||||||
|
for apt in appointments[:10]: # Show first 10
|
||||||
|
report += f"- {apt['title']} ({apt['status']})\\n"
|
||||||
|
|
||||||
|
# Get customizable email settings
|
||||||
|
owner_email = '{{PROMPT:owner_email|Owner Email}}'
|
||||||
|
email_subject = '{{PROMPT:email_subject|Email Subject|Monthly Revenue Report}}'
|
||||||
|
|
||||||
|
# Send report
|
||||||
|
api.send_email(
|
||||||
|
to=owner_email,
|
||||||
|
subject=email_subject,
|
||||||
|
body=report
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
'logo_url': '/plugin-logos/monthly-revenue-report.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Appointment Reminder (24hr)',
|
'name': 'Appointment Reminder (24hr)',
|
||||||
@@ -90,7 +229,33 @@ This plugin sends friendly reminder emails to customers 24 hours before their sc
|
|||||||
- Cancellation policy reminder
|
- Cancellation policy reminder
|
||||||
|
|
||||||
Studies show that appointment reminders can reduce no-shows by up to 90%.''',
|
Studies show that appointment reminders can reduce no-shows by up to 90%.''',
|
||||||
'plugin_code': 'Get appointments 24hrs from now, send reminder emails with {{PROMPT:custom_message|Custom message}}',
|
'plugin_code': '''from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Get appointments 24 hours from now
|
||||||
|
tomorrow = (datetime.now() + timedelta(days=1)).date()
|
||||||
|
appointments = api.get_appointments(
|
||||||
|
start_date=tomorrow.isoformat(),
|
||||||
|
end_date=tomorrow.isoformat(),
|
||||||
|
status='SCHEDULED'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get customizable email template
|
||||||
|
email_subject = '{{PROMPT:email_subject|Email Subject|Reminder: Your Appointment Tomorrow}}'
|
||||||
|
email_body = '{{PROMPT:email_body|Email Message|Hi {{CUSTOMER_NAME}},\n\nThis is a friendly reminder about your appointment:\n\nDate/Time: {{APPOINTMENT_TIME}}\nService: {{APPOINTMENT_SERVICE}}\n\nPlease arrive 10 minutes early.\n\nIf you need to cancel or reschedule, please let us know as soon as possible.\n\nBest regards,\n{{BUSINESS_NAME}}||textarea}}'
|
||||||
|
|
||||||
|
# Send reminders
|
||||||
|
for apt in appointments:
|
||||||
|
customer_id = apt.get('customer_id')
|
||||||
|
if customer_id:
|
||||||
|
api.send_email(
|
||||||
|
to=customer_id,
|
||||||
|
subject=email_subject,
|
||||||
|
body=email_body
|
||||||
|
)
|
||||||
|
|
||||||
|
api.log(f"Sent {len(appointments)} appointment reminders")
|
||||||
|
''',
|
||||||
|
'logo_url': '/plugin-logos/appointment-reminder-24hr.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Inactive Customer Re-engagement',
|
'name': 'Inactive Customer Re-engagement',
|
||||||
@@ -106,7 +271,44 @@ This plugin automatically identifies customers who haven\'t made an appointment
|
|||||||
- Easy booking links
|
- Easy booking links
|
||||||
|
|
||||||
Configurable inactivity period (default: 60 days). A proven strategy for increasing customer lifetime value and reducing churn.''',
|
Configurable inactivity period (default: 60 days). A proven strategy for increasing customer lifetime value and reducing churn.''',
|
||||||
'plugin_code': 'Find customers inactive for {{PROMPT:inactive_days|Days inactive|60}} days, send comeback offer with {{PROMPT:discount_code|Discount code}}',
|
'plugin_code': '''from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Get configuration
|
||||||
|
inactive_days = int('{{PROMPT:inactive_days|Days Inactive|60}}')
|
||||||
|
discount_code = '{{PROMPT:discount_code|Discount Code}}'
|
||||||
|
email_subject = '{{PROMPT:email_subject|Email Subject|We Miss You! Come Back Soon}}'
|
||||||
|
email_body = '{{PROMPT:email_body|Email Message|Hi {{CUSTOMER_NAME}},\n\nWe noticed it\'s been a while since your last visit, and we wanted to reach out.\n\nWe\'d love to see you again! As a special welcome back offer, use code {discount_code} on your next appointment.\n\nBook now and let us take care of you!\n\nBest regards,\n{{BUSINESS_NAME}}||textarea}}'
|
||||||
|
|
||||||
|
# Get recent appointments to find active customers
|
||||||
|
cutoff_date = (datetime.now() - timedelta(days=inactive_days)).date()
|
||||||
|
recent_appointments = api.get_appointments(
|
||||||
|
start_date=cutoff_date.isoformat(),
|
||||||
|
end_date=datetime.now().date().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get active customer IDs
|
||||||
|
active_customer_ids = set(apt.get('customer_id') for apt in recent_appointments if apt.get('customer_id'))
|
||||||
|
|
||||||
|
# Get all customers
|
||||||
|
all_customers = api.get_customers(has_email=True, limit=1000)
|
||||||
|
|
||||||
|
# Find inactive customers and send re-engagement emails
|
||||||
|
inactive_count = 0
|
||||||
|
for customer in all_customers:
|
||||||
|
if customer['id'] not in active_customer_ids:
|
||||||
|
# Format email body with discount code
|
||||||
|
formatted_body = email_body.format(discount_code=discount_code)
|
||||||
|
|
||||||
|
api.send_email(
|
||||||
|
to=customer['email'],
|
||||||
|
subject=email_subject,
|
||||||
|
body=formatted_body
|
||||||
|
)
|
||||||
|
inactive_count += 1
|
||||||
|
|
||||||
|
api.log(f"Sent re-engagement emails to {inactive_count} inactive customers")
|
||||||
|
''',
|
||||||
|
'logo_url': '/plugin-logos/inactive-customer-reengagement.png',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -130,6 +332,7 @@ Configurable inactivity period (default: 60 days). A proven strategy for increas
|
|||||||
short_description=plugin_data['short_description'],
|
short_description=plugin_data['short_description'],
|
||||||
description=plugin_data['description'],
|
description=plugin_data['description'],
|
||||||
plugin_code=plugin_data['plugin_code'],
|
plugin_code=plugin_data['plugin_code'],
|
||||||
|
logo_url=plugin_data.get('logo_url', ''),
|
||||||
visibility=PluginTemplate.Visibility.PLATFORM,
|
visibility=PluginTemplate.Visibility.PLATFORM,
|
||||||
is_approved=True,
|
is_approved=True,
|
||||||
approved_at=timezone.now(),
|
approved_at=timezone.now(),
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-29 03:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0016_plugintemplate_version'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plugintemplate',
|
||||||
|
name='logo_url',
|
||||||
|
field=models.URLField(blank=True, help_text='URL to plugin logo/icon image', max_length=500),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-29 04:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0017_plugintemplate_logo_url'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='plugininstallation',
|
||||||
|
name='scheduled_task',
|
||||||
|
field=models.OneToOneField(blank=True, help_text='Optional scheduled task if plugin is scheduled to run automatically', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='installation', to='schedule.scheduledtask'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-29 04:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0018_alter_plugininstallation_scheduled_task'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='event',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('SCHEDULED', 'Scheduled'), ('CANCELED', 'Canceled'), ('COMPLETED', 'Completed'), ('PAID', 'Paid'), ('NOSHOW', 'No Show')], db_index=True, default='SCHEDULED', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EventPlugin',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('trigger', models.CharField(choices=[('event_created', 'When Event is Created'), ('event_updated', 'When Event is Updated'), ('event_completed', 'When Event is Completed'), ('event_canceled', 'When Event is Canceled'), ('before_start', 'Before Event Starts'), ('after_end', 'After Event Ends')], default='event_created', help_text='When this plugin should execute', max_length=20)),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Whether this plugin should run for this event')),
|
||||||
|
('execution_order', models.PositiveSmallIntegerField(default=0, help_text='Order of execution (lower numbers run first)')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='schedule.event')),
|
||||||
|
('plugin_installation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='schedule.plugininstallation')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['execution_order', 'created_at'],
|
||||||
|
'unique_together': {('event', 'plugin_installation', 'trigger')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='event',
|
||||||
|
name='plugins',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Plugins attached to this event for automation', related_name='events', through='schedule.EventPlugin', to='schedule.plugininstallation'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -165,13 +165,15 @@ class Resource(models.Model):
|
|||||||
class Event(models.Model):
|
class Event(models.Model):
|
||||||
"""
|
"""
|
||||||
A scheduled event. Links to Resources/Staff/Customers via Participant model.
|
A scheduled event. Links to Resources/Staff/Customers via Participant model.
|
||||||
|
Can also have attached plugins for automation.
|
||||||
"""
|
"""
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
||||||
CANCELED = 'CANCELED', 'Canceled'
|
CANCELED = 'CANCELED', 'Canceled'
|
||||||
COMPLETED = 'COMPLETED', 'Completed'
|
COMPLETED = 'COMPLETED', 'Completed'
|
||||||
PAID = 'PAID', 'Paid'
|
PAID = 'PAID', 'Paid'
|
||||||
|
NOSHOW = 'NOSHOW', 'No Show'
|
||||||
|
|
||||||
title = models.CharField(max_length=200)
|
title = models.CharField(max_length=200)
|
||||||
start_time = models.DateTimeField(db_index=True)
|
start_time = models.DateTimeField(db_index=True)
|
||||||
end_time = models.DateTimeField(db_index=True)
|
end_time = models.DateTimeField(db_index=True)
|
||||||
@@ -180,6 +182,15 @@ class Event(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
created_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='created_events')
|
created_by = models.ForeignKey('users.User', on_delete=models.SET_NULL, null=True, related_name='created_events')
|
||||||
|
|
||||||
|
# Plugin attachments for resource-free scheduling
|
||||||
|
plugins = models.ManyToManyField(
|
||||||
|
'PluginInstallation',
|
||||||
|
through='EventPlugin',
|
||||||
|
related_name='events',
|
||||||
|
blank=True,
|
||||||
|
help_text="Plugins attached to this event for automation"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['start_time']
|
ordering = ['start_time']
|
||||||
@@ -196,6 +207,108 @@ class Event(models.Model):
|
|||||||
"""Calculate event duration"""
|
"""Calculate event duration"""
|
||||||
return self.end_time - self.start_time
|
return self.end_time - self.start_time
|
||||||
|
|
||||||
|
def execute_plugins(self, trigger='event_created'):
|
||||||
|
"""
|
||||||
|
Execute all attached plugins for this event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trigger: What triggered the execution ('event_created', 'event_completed', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of execution results
|
||||||
|
"""
|
||||||
|
from .safe_scripting import SafeScriptRunner, SafeScriptAPI
|
||||||
|
from .template_parser import TemplateVariableParser
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for event_plugin in self.eventplugin_set.filter(trigger=trigger, is_active=True):
|
||||||
|
installation = event_plugin.plugin_installation
|
||||||
|
template = installation.template
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Compile template with config values
|
||||||
|
compiled_code = TemplateVariableParser.compile_template(
|
||||||
|
template.plugin_code,
|
||||||
|
installation.config_values
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute plugin
|
||||||
|
runner = SafeScriptRunner()
|
||||||
|
api = SafeScriptAPI(business=None) # TODO: Get business from tenant
|
||||||
|
|
||||||
|
# Add event context to API
|
||||||
|
api._event_context = {
|
||||||
|
'event_id': self.id,
|
||||||
|
'event_title': self.title,
|
||||||
|
'start_time': self.start_time.isoformat(),
|
||||||
|
'end_time': self.end_time.isoformat(),
|
||||||
|
'status': self.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = runner.execute(compiled_code, api)
|
||||||
|
results.append({
|
||||||
|
'plugin': template.name,
|
||||||
|
'success': result['success'],
|
||||||
|
'output': result.get('output'),
|
||||||
|
'error': result.get('error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results.append({
|
||||||
|
'plugin': template.name if template else 'Unknown',
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class EventPlugin(models.Model):
|
||||||
|
"""
|
||||||
|
Through model for Event-Plugin relationship.
|
||||||
|
Allows configuring when and how a plugin runs for an event.
|
||||||
|
"""
|
||||||
|
class Trigger(models.TextChoices):
|
||||||
|
EVENT_CREATED = 'event_created', 'When Event is Created'
|
||||||
|
EVENT_UPDATED = 'event_updated', 'When Event is Updated'
|
||||||
|
EVENT_COMPLETED = 'event_completed', 'When Event is Completed'
|
||||||
|
EVENT_CANCELED = 'event_canceled', 'When Event is Canceled'
|
||||||
|
BEFORE_START = 'before_start', 'Before Event Starts'
|
||||||
|
AFTER_END = 'after_end', 'After Event Ends'
|
||||||
|
|
||||||
|
event = models.ForeignKey(Event, on_delete=models.CASCADE)
|
||||||
|
plugin_installation = models.ForeignKey('PluginInstallation', on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
trigger = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Trigger.choices,
|
||||||
|
default=Trigger.EVENT_CREATED,
|
||||||
|
help_text="When this plugin should execute"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this plugin should run for this event"
|
||||||
|
)
|
||||||
|
|
||||||
|
execution_order = models.PositiveSmallIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Order of execution (lower numbers run first)"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['execution_order', 'created_at']
|
||||||
|
unique_together = ['event', 'plugin_installation', 'trigger']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
plugin_name = self.plugin_installation.template.name if self.plugin_installation.template else 'Unknown'
|
||||||
|
return f"{self.event.title} - {plugin_name} ({self.trigger})"
|
||||||
|
|
||||||
|
|
||||||
class Participant(models.Model):
|
class Participant(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -656,6 +769,12 @@ class PluginTemplate(models.Model):
|
|||||||
help_text="One-line summary for marketplace listing"
|
help_text="One-line summary for marketplace listing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logo_url = models.URLField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
help_text="URL to plugin logo/icon image"
|
||||||
|
)
|
||||||
|
|
||||||
# Code & Configuration
|
# Code & Configuration
|
||||||
plugin_code = models.TextField(
|
plugin_code = models.TextField(
|
||||||
help_text="The Python script code"
|
help_text="The Python script code"
|
||||||
@@ -795,6 +914,7 @@ class PluginTemplate(models.Model):
|
|||||||
"""Generate slug and code hash on save"""
|
"""Generate slug and code hash on save"""
|
||||||
import hashlib
|
import hashlib
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from .template_parser import TemplateVariableParser
|
||||||
|
|
||||||
# Generate slug if not set
|
# Generate slug if not set
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
@@ -812,6 +932,11 @@ class PluginTemplate(models.Model):
|
|||||||
self.plugin_code.encode('utf-8')
|
self.plugin_code.encode('utf-8')
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
|
||||||
|
# Parse template variables from plugin code
|
||||||
|
variables_list = TemplateVariableParser.extract_variables(self.plugin_code)
|
||||||
|
# Convert list to dict keyed by variable name
|
||||||
|
self.template_variables = {var['name']: var for var in variables_list}
|
||||||
|
|
||||||
# Set author name if not provided
|
# Set author name if not provided
|
||||||
if self.author and not self.author_name:
|
if self.author and not self.author_name:
|
||||||
self.author_name = self.author.get_full_name() or self.author.username
|
self.author_name = self.author.get_full_name() or self.author.username
|
||||||
@@ -843,11 +968,12 @@ class PluginTemplate(models.Model):
|
|||||||
|
|
||||||
class PluginInstallation(models.Model):
|
class PluginInstallation(models.Model):
|
||||||
"""
|
"""
|
||||||
Tracks installation of a plugin template into a ScheduledTask.
|
Tracks installation of a plugin template.
|
||||||
|
|
||||||
When a user installs a plugin from the marketplace, we create:
|
When a user installs a plugin from the marketplace:
|
||||||
1. A ScheduledTask with the configured code
|
1. A PluginInstallation record is created (makes it available in "My Plugins")
|
||||||
2. A PluginInstallation record linking template -> task
|
2. Optionally, a ScheduledTask can be created later for automatic execution
|
||||||
|
3. Plugin can also be used for resource-free scheduling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template = models.ForeignKey(
|
template = models.ForeignKey(
|
||||||
@@ -862,7 +988,9 @@ class PluginInstallation(models.Model):
|
|||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='installation',
|
related_name='installation',
|
||||||
help_text="The created scheduled task"
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Optional scheduled task if plugin is scheduled to run automatically"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Installation metadata
|
# Installation metadata
|
||||||
|
|||||||
@@ -144,10 +144,13 @@ class SafeScriptAPI:
|
|||||||
Args:
|
Args:
|
||||||
to: Email address or customer ID
|
to: Email address or customer ID
|
||||||
subject: Email subject
|
subject: Email subject
|
||||||
body: Email body (plain text)
|
body: Email body (plain text, may contain insertion codes)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if sent successfully
|
True if sent successfully
|
||||||
|
|
||||||
|
Note: body can contain insertion codes like {business_name}, {customer_name}, etc.
|
||||||
|
These are automatically populated from the execution context.
|
||||||
"""
|
"""
|
||||||
self._check_api_limit()
|
self._check_api_limit()
|
||||||
|
|
||||||
@@ -167,6 +170,24 @@ class SafeScriptAPI:
|
|||||||
if not to or '@' not in to:
|
if not to or '@' not in to:
|
||||||
raise ScriptExecutionError(f"Invalid email address: {to}")
|
raise ScriptExecutionError(f"Invalid email address: {to}")
|
||||||
|
|
||||||
|
# Process insertion codes in subject and body if they contain f-string patterns
|
||||||
|
# The insertion codes were already converted to {variable_name} format by template parser
|
||||||
|
# We need to evaluate them as f-strings with the context variables
|
||||||
|
try:
|
||||||
|
# Get context variables for insertion codes
|
||||||
|
context = self._get_insertion_context()
|
||||||
|
|
||||||
|
# Process subject and body as f-strings
|
||||||
|
if '{' in subject:
|
||||||
|
subject = subject.format(**context)
|
||||||
|
if '{' in body:
|
||||||
|
body = body.format(**context)
|
||||||
|
except KeyError as e:
|
||||||
|
raise ScriptExecutionError(f"Unknown insertion code: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error processing insertion codes: {e}")
|
||||||
|
# Continue with unprocessed text if there's an error
|
||||||
|
|
||||||
# Length limits
|
# Length limits
|
||||||
if len(subject) > 200:
|
if len(subject) > 200:
|
||||||
raise ScriptExecutionError("Subject too long (max 200 characters)")
|
raise ScriptExecutionError("Subject too long (max 200 characters)")
|
||||||
@@ -189,7 +210,43 @@ class SafeScriptAPI:
|
|||||||
def log(self, message):
|
def log(self, message):
|
||||||
"""Log a message (for debugging)"""
|
"""Log a message (for debugging)"""
|
||||||
logger.info(f"[Customer Script] {message}")
|
logger.info(f"[Customer Script] {message}")
|
||||||
return message
|
|
||||||
|
def _get_insertion_context(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get context variables for insertion codes.
|
||||||
|
|
||||||
|
Returns dict with business info and date/time values that can be used
|
||||||
|
in email templates via insertion codes.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Get business info from tenant
|
||||||
|
business_name = getattr(self.business, 'name', '') if self.business else ''
|
||||||
|
business_email = getattr(self.business, 'contact_email', '') if self.business else ''
|
||||||
|
business_phone = getattr(self.business, 'phone', '') if self.business else ''
|
||||||
|
|
||||||
|
# Date/time values
|
||||||
|
now = datetime.now()
|
||||||
|
today_str = now.strftime('%Y-%m-%d')
|
||||||
|
now_str = now.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# Build context dict
|
||||||
|
# These variable names match what _mark_insertions_for_runtime() produces
|
||||||
|
context = {
|
||||||
|
'business_name': business_name,
|
||||||
|
'business_email': business_email,
|
||||||
|
'business_phone': business_phone,
|
||||||
|
'today': today_str,
|
||||||
|
'now': now_str,
|
||||||
|
# Appointment-specific fields (empty if not in appointment context)
|
||||||
|
'customer_name': '',
|
||||||
|
'customer_email': '',
|
||||||
|
'appointment_time': '',
|
||||||
|
'appointment_date': '',
|
||||||
|
'appointment_service': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
def _validate_url(self, url, method='GET'):
|
def _validate_url(self, url, method='GET'):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ class PluginTemplateSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'name', 'slug', 'description', 'short_description',
|
'id', 'name', 'slug', 'description', 'short_description',
|
||||||
'plugin_code', 'plugin_code_hash', 'template_variables', 'default_config',
|
'plugin_code', 'plugin_code_hash', 'template_variables', 'default_config',
|
||||||
'visibility', 'category', 'tags',
|
'visibility', 'category', 'tags',
|
||||||
'author', 'author_name', 'version', 'license_type',
|
'author', 'author_name', 'version', 'license_type', 'logo_url',
|
||||||
'is_approved', 'approved_by', 'approved_by_name', 'approved_at', 'rejection_reason',
|
'is_approved', 'approved_by', 'approved_by_name', 'approved_at', 'rejection_reason',
|
||||||
'install_count', 'rating_average', 'rating_count',
|
'install_count', 'rating_average', 'rating_count',
|
||||||
'created_at', 'updated_at', 'published_at',
|
'created_at', 'updated_at', 'published_at',
|
||||||
@@ -628,7 +628,7 @@ class PluginTemplateListSerializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'short_description', 'description',
|
'id', 'name', 'slug', 'short_description', 'description',
|
||||||
'visibility', 'category', 'tags',
|
'visibility', 'category', 'tags',
|
||||||
'author_name', 'version', 'license_type', 'is_approved',
|
'author_name', 'version', 'license_type', 'logo_url', 'is_approved',
|
||||||
'install_count', 'rating_average', 'rating_count',
|
'install_count', 'rating_average', 'rating_count',
|
||||||
'created_at', 'updated_at', 'published_at',
|
'created_at', 'updated_at', 'published_at',
|
||||||
]
|
]
|
||||||
@@ -640,6 +640,12 @@ class PluginInstallationSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
template_name = serializers.CharField(source='template.name', read_only=True)
|
template_name = serializers.CharField(source='template.name', read_only=True)
|
||||||
template_slug = serializers.CharField(source='template.slug', read_only=True)
|
template_slug = serializers.CharField(source='template.slug', read_only=True)
|
||||||
|
template_description = serializers.CharField(source='template.description', read_only=True)
|
||||||
|
category = serializers.CharField(source='template.category', read_only=True)
|
||||||
|
version = serializers.CharField(source='template.version', read_only=True)
|
||||||
|
author_name = serializers.CharField(source='template.author_name', read_only=True)
|
||||||
|
logo_url = serializers.CharField(source='template.logo_url', read_only=True)
|
||||||
|
template_variables = serializers.JSONField(source='template.template_variables', read_only=True)
|
||||||
scheduled_task_name = serializers.CharField(source='scheduled_task.name', read_only=True)
|
scheduled_task_name = serializers.CharField(source='scheduled_task.name', read_only=True)
|
||||||
installed_by_name = serializers.SerializerMethodField()
|
installed_by_name = serializers.SerializerMethodField()
|
||||||
has_update = serializers.SerializerMethodField()
|
has_update = serializers.SerializerMethodField()
|
||||||
@@ -647,7 +653,8 @@ class PluginInstallationSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = PluginInstallation
|
model = PluginInstallation
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'template', 'template_name', 'template_slug',
|
'id', 'template', 'template_name', 'template_slug', 'template_description',
|
||||||
|
'category', 'version', 'author_name', 'logo_url', 'template_variables',
|
||||||
'scheduled_task', 'scheduled_task_name',
|
'scheduled_task', 'scheduled_task_name',
|
||||||
'installed_by', 'installed_by_name', 'installed_at',
|
'installed_by', 'installed_by_name', 'installed_at',
|
||||||
'config_values', 'template_version_hash',
|
'config_values', 'template_version_hash',
|
||||||
@@ -668,3 +675,30 @@ class PluginInstallationSerializer(serializers.ModelSerializer):
|
|||||||
def get_has_update(self, obj):
|
def get_has_update(self, obj):
|
||||||
"""Check if template has been updated"""
|
"""Check if template has been updated"""
|
||||||
return obj.has_update_available()
|
return obj.has_update_available()
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""
|
||||||
|
Create plugin installation.
|
||||||
|
|
||||||
|
Installation makes the plugin available in "My Plugins".
|
||||||
|
Scheduling is optional and done separately.
|
||||||
|
"""
|
||||||
|
request = self.context.get('request')
|
||||||
|
template = validated_data.get('template')
|
||||||
|
|
||||||
|
# Set installed_by from request user
|
||||||
|
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
|
validated_data['installed_by'] = request.user
|
||||||
|
|
||||||
|
# Store template version hash for update detection
|
||||||
|
if template:
|
||||||
|
import hashlib
|
||||||
|
validated_data['template_version_hash'] = hashlib.sha256(
|
||||||
|
template.plugin_code.encode('utf-8')
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
# Don't require scheduled_task on creation
|
||||||
|
# It can be added later when user schedules the plugin
|
||||||
|
validated_data.pop('scheduled_task', None)
|
||||||
|
|
||||||
|
return super().create(validated_data)
|
||||||
|
|||||||
@@ -4,8 +4,21 @@ Template Variable Parser for Plugin System
|
|||||||
Parses template variables with formats:
|
Parses template variables with formats:
|
||||||
- {{PROMPT:variable_name|description}} - User input
|
- {{PROMPT:variable_name|description}} - User input
|
||||||
- {{PROMPT:variable_name|description|default}} - User input with default value
|
- {{PROMPT:variable_name|description|default}} - User input with default value
|
||||||
|
- {{PROMPT:variable_name|description|default|textarea}} - Multi-line text input
|
||||||
- {{CONTEXT:field_name}} - Auto-filled business context
|
- {{CONTEXT:field_name}} - Auto-filled business context
|
||||||
- {{DATE:expression}} - Date/time helpers
|
- {{DATE:expression}} - Date/time helpers
|
||||||
|
|
||||||
|
Insertion codes for use within PROMPT templates (e.g., email bodies):
|
||||||
|
- {{BUSINESS_NAME}} - Business name
|
||||||
|
- {{BUSINESS_EMAIL}} - Business contact email
|
||||||
|
- {{BUSINESS_PHONE}} - Business phone number
|
||||||
|
- {{CUSTOMER_NAME}} - Customer's name (in appointment contexts)
|
||||||
|
- {{CUSTOMER_EMAIL}} - Customer's email (in appointment contexts)
|
||||||
|
- {{APPOINTMENT_TIME}} - Appointment date/time (in appointment contexts)
|
||||||
|
- {{APPOINTMENT_DATE}} - Appointment date only
|
||||||
|
- {{APPOINTMENT_SERVICE}} - Service name
|
||||||
|
- {{TODAY}} - Today's date
|
||||||
|
- {{NOW}} - Current date and time
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -17,9 +30,9 @@ from datetime import datetime, timedelta
|
|||||||
class TemplateVariableParser:
|
class TemplateVariableParser:
|
||||||
"""Parse and process template variables"""
|
"""Parse and process template variables"""
|
||||||
|
|
||||||
# Pattern matches: {{PROMPT:variable_name|description}} or {{PROMPT:variable_name|description|default}}
|
# Pattern matches: {{PROMPT:variable_name|...}}
|
||||||
# Groups: (variable_name, description, optional_default)
|
# We'll extract the variable name and manually parse the content
|
||||||
VARIABLE_PATTERN = r'\{\{PROMPT:([a-z_][a-z0-9_]*)\|([^}|]+)(?:\|([^}]+))?\}\}'
|
VARIABLE_PATTERN_START = r'\{\{PROMPT:([a-z_][a-z0-9_]*)\|'
|
||||||
|
|
||||||
# Pattern for context variables: {{CONTEXT:field_name}}
|
# Pattern for context variables: {{CONTEXT:field_name}}
|
||||||
CONTEXT_PATTERN = r'\{\{CONTEXT:([a-z_][a-z0-9_]*)\}\}'
|
CONTEXT_PATTERN = r'\{\{CONTEXT:([a-z_][a-z0-9_]*)\}\}'
|
||||||
@@ -27,6 +40,9 @@ class TemplateVariableParser:
|
|||||||
# Pattern for date helpers: {{DATE:expression}}
|
# Pattern for date helpers: {{DATE:expression}}
|
||||||
DATE_PATTERN = r'\{\{DATE:([^}]+)\}\}'
|
DATE_PATTERN = r'\{\{DATE:([^}]+)\}\}'
|
||||||
|
|
||||||
|
# Pattern for insertion codes: {{BUSINESS_NAME}}, {{CUSTOMER_NAME}}, etc.
|
||||||
|
INSERTION_PATTERN = r'\{\{(BUSINESS_NAME|BUSINESS_EMAIL|BUSINESS_PHONE|CUSTOMER_NAME|CUSTOMER_EMAIL|APPOINTMENT_TIME|APPOINTMENT_DATE|APPOINTMENT_SERVICE|TODAY|NOW)\}\}'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def extract_variables(cls, template: str) -> List[Dict[str, str]]:
|
def extract_variables(cls, template: str) -> List[Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
@@ -49,22 +65,59 @@ class TemplateVariableParser:
|
|||||||
...
|
...
|
||||||
]
|
]
|
||||||
"""
|
"""
|
||||||
matches = re.findall(cls.VARIABLE_PATTERN, template)
|
|
||||||
variables = []
|
variables = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|
||||||
for match in matches:
|
# Find all PROMPT variable starts
|
||||||
var_name = match[0]
|
for match in re.finditer(cls.VARIABLE_PATTERN_START, template):
|
||||||
description = match[1]
|
var_name = match.group(1)
|
||||||
default_value = match[2] if len(match) > 2 and match[2] else None
|
|
||||||
|
|
||||||
# Skip duplicates (keep first occurrence)
|
# Skip duplicates (keep first occurrence)
|
||||||
if var_name in seen:
|
if var_name in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(var_name)
|
seen.add(var_name)
|
||||||
|
|
||||||
|
# Find the matching }} by counting braces
|
||||||
|
start_pos = match.end()
|
||||||
|
brace_count = 1 # We're inside {{PROMPT:var|
|
||||||
|
end_pos = start_pos
|
||||||
|
|
||||||
|
while end_pos < len(template) - 1 and brace_count > 0:
|
||||||
|
if template[end_pos:end_pos+2] == '{{':
|
||||||
|
brace_count += 1
|
||||||
|
end_pos += 2
|
||||||
|
elif template[end_pos:end_pos+2] == '}}':
|
||||||
|
brace_count -= 1
|
||||||
|
if brace_count == 0:
|
||||||
|
break
|
||||||
|
end_pos += 2
|
||||||
|
else:
|
||||||
|
end_pos += 1
|
||||||
|
|
||||||
|
# Extract the content between var_name| and }}
|
||||||
|
content = template[start_pos:end_pos]
|
||||||
|
|
||||||
|
# Parse the content manually to handle nested {{ }}
|
||||||
|
# Split by | but need to handle {{ }} pairs
|
||||||
|
parts = cls._split_preserving_braces(content)
|
||||||
|
|
||||||
|
description = parts[0].strip() if len(parts) > 0 else var_name
|
||||||
|
default_value = parts[1].strip() if len(parts) > 1 and parts[1].strip() else None
|
||||||
|
# Explicit type can be in parts[2] or parts[3] depending on whether default is provided
|
||||||
|
# Format: description|default|type or description||type
|
||||||
|
explicit_type = None
|
||||||
|
if len(parts) > 3 and parts[3].strip():
|
||||||
|
explicit_type = parts[3].strip()
|
||||||
|
elif len(parts) > 2 and parts[2].strip():
|
||||||
|
explicit_type = parts[2].strip()
|
||||||
|
|
||||||
label = cls._variable_to_label(var_name)
|
label = cls._variable_to_label(var_name)
|
||||||
var_type = cls._infer_type(var_name, description)
|
|
||||||
|
# Use explicit type if provided, otherwise infer
|
||||||
|
if explicit_type and explicit_type.strip().lower() in ['text', 'textarea', 'email', 'number', 'url']:
|
||||||
|
var_type = explicit_type.strip().lower()
|
||||||
|
else:
|
||||||
|
var_type = cls._infer_type(var_name, description)
|
||||||
|
|
||||||
variables.append({
|
variables.append({
|
||||||
'name': var_name,
|
'name': var_name,
|
||||||
@@ -78,6 +131,42 @@ class TemplateVariableParser:
|
|||||||
|
|
||||||
return variables
|
return variables
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _split_preserving_braces(cls, text: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Split text by | while preserving {{ }} pairs.
|
||||||
|
|
||||||
|
Example: "Email Message|Hi {{NAME}}||textarea" -> ["Email Message", "Hi {{NAME}}", "", "textarea"]
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
current = []
|
||||||
|
brace_depth = 0
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(text):
|
||||||
|
if i < len(text) - 1 and text[i:i+2] == '{{':
|
||||||
|
brace_depth += 1
|
||||||
|
current.append(text[i:i+2])
|
||||||
|
i += 2
|
||||||
|
elif i < len(text) - 1 and text[i:i+2] == '}}':
|
||||||
|
brace_depth -= 1
|
||||||
|
current.append(text[i:i+2])
|
||||||
|
i += 2
|
||||||
|
elif text[i] == '|' and brace_depth == 0:
|
||||||
|
# Split here
|
||||||
|
parts.append(''.join(current))
|
||||||
|
current = []
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
current.append(text[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Add final part
|
||||||
|
if current or (text and text[-1] == '|'):
|
||||||
|
parts.append(''.join(current))
|
||||||
|
|
||||||
|
return parts
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _variable_to_label(cls, var_name: str) -> str:
|
def _variable_to_label(cls, var_name: str) -> str:
|
||||||
"""Convert snake_case to Title Case"""
|
"""Convert snake_case to Title Case"""
|
||||||
@@ -165,13 +254,17 @@ class TemplateVariableParser:
|
|||||||
if value is None or value == '':
|
if value is None or value == '':
|
||||||
raise ValueError(f"Missing required configuration: {var_name}")
|
raise ValueError(f"Missing required configuration: {var_name}")
|
||||||
|
|
||||||
# Sanitize: Check for HTML tags
|
# Sanitize: Check for HTML tags (but allow insertion codes)
|
||||||
if cls._contains_html(value):
|
if cls._contains_html_excluding_insertions(value):
|
||||||
raise ValueError(f"HTML not allowed in configuration field: {var_name}")
|
raise ValueError(f"HTML not allowed in configuration field: {var_name}")
|
||||||
|
|
||||||
|
# Process insertion codes within the value before escaping
|
||||||
|
# This allows users to use {{BUSINESS_NAME}}, {{CUSTOMER_NAME}}, etc. in their text
|
||||||
|
processed_value = cls._mark_insertions_for_runtime(str(value))
|
||||||
|
|
||||||
# Escape the value for Python string safety
|
# Escape the value for Python string safety
|
||||||
# This ensures strings are properly quoted in the compiled script
|
# This ensures strings are properly quoted in the compiled script
|
||||||
return repr(str(value))
|
return repr(processed_value)
|
||||||
|
|
||||||
compiled = re.sub(cls.VARIABLE_PATTERN, replace_var, compiled)
|
compiled = re.sub(cls.VARIABLE_PATTERN, replace_var, compiled)
|
||||||
|
|
||||||
@@ -309,6 +402,54 @@ class TemplateVariableParser:
|
|||||||
html_pattern = r'<[^>]+>'
|
html_pattern = r'<[^>]+>'
|
||||||
return bool(re.search(html_pattern, str(value)))
|
return bool(re.search(html_pattern, str(value)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _contains_html_excluding_insertions(cls, value: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if string contains HTML tags, but allow insertion codes like {{BUSINESS_NAME}}.
|
||||||
|
|
||||||
|
Returns True if HTML tags are detected (excluding insertion codes).
|
||||||
|
"""
|
||||||
|
# Temporarily replace insertion codes with placeholders
|
||||||
|
temp_value = re.sub(cls.INSERTION_PATTERN, '___INSERTION___', str(value))
|
||||||
|
|
||||||
|
# Check for common HTML tags in the modified string
|
||||||
|
html_pattern = r'<[^>]+>'
|
||||||
|
return bool(re.search(html_pattern, temp_value))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _mark_insertions_for_runtime(cls, value: str) -> str:
|
||||||
|
"""
|
||||||
|
Mark insertion codes to be processed at runtime via f-string interpolation.
|
||||||
|
|
||||||
|
Converts {{BUSINESS_NAME}} to {business_name}, {{CUSTOMER_NAME}} to {customer_name}, etc.
|
||||||
|
These will be filled in by the SafeScriptAPI at runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: String containing insertion codes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String with insertion codes converted to Python f-string format
|
||||||
|
"""
|
||||||
|
# Map insertion codes to runtime variable names
|
||||||
|
insertion_map = {
|
||||||
|
'BUSINESS_NAME': '{business_name}',
|
||||||
|
'BUSINESS_EMAIL': '{business_email}',
|
||||||
|
'BUSINESS_PHONE': '{business_phone}',
|
||||||
|
'CUSTOMER_NAME': '{customer_name}',
|
||||||
|
'CUSTOMER_EMAIL': '{customer_email}',
|
||||||
|
'APPOINTMENT_TIME': '{appointment_time}',
|
||||||
|
'APPOINTMENT_DATE': '{appointment_date}',
|
||||||
|
'APPOINTMENT_SERVICE': '{appointment_service}',
|
||||||
|
'TODAY': '{today}',
|
||||||
|
'NOW': '{now}',
|
||||||
|
}
|
||||||
|
|
||||||
|
def replace_insertion(match):
|
||||||
|
code = match.group(1)
|
||||||
|
return insertion_map.get(code, match.group(0))
|
||||||
|
|
||||||
|
return re.sub(cls.INSERTION_PATTERN, replace_insertion, value)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _is_valid_email(cls, value: str) -> bool:
|
def _is_valid_email(cls, value: str) -> bool:
|
||||||
"""Basic email validation"""
|
"""Basic email validation"""
|
||||||
|
|||||||