feat(admin): analytics user breakdowns, email templates, and team invites
Surface education, occupation, learning goals, and language challenges on the analytics page with normalized dashboard API parsing. Add email template management, accept-invite onboarding, and role-based team invitations. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b8a73c73db
commit
e75420e756
82
package-lock.json
generated
82
package-lock.json
generated
|
|
@ -26,6 +26,7 @@
|
||||||
"react-is": "^19.2.5",
|
"react-is": "^19.2.5",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
|
"resend": "^6.12.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|
@ -92,6 +93,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -360,6 +362,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/accessibility": "^3.1.1",
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -2670,6 +2673,12 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
|
@ -2810,6 +2819,7 @@
|
||||||
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2820,6 +2830,7 @@
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2830,6 +2841,7 @@
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2885,6 +2897,7 @@
|
||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
|
|
@ -3136,6 +3149,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3374,6 +3388,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -3950,6 +3965,7 @@
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -4185,6 +4201,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
|
|
@ -5064,6 +5086,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5091,6 +5114,12 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
|
||||||
|
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|
@ -5111,6 +5140,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -5299,6 +5329,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5308,6 +5339,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -5319,13 +5351,15 @@
|
||||||
"version": "19.2.5",
|
"version": "19.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
|
||||||
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
"node_modules/react-redux": {
|
||||||
"version": "9.2.0",
|
"version": "9.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
|
@ -5531,7 +5565,8 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|
@ -5548,6 +5583,27 @@
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.4",
|
||||||
|
"svix": "1.92.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
|
@ -5721,6 +5777,16 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
|
|
@ -5783,6 +5849,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svix": {
|
||||||
|
"version": "1.92.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz",
|
||||||
|
"integrity": "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"standardwebhooks": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
|
|
@ -5935,6 +6010,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -6102,6 +6178,7 @@
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -6239,6 +6316,7 @@
|
||||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"react-is": "^19.2.5",
|
"react-is": "^19.2.5",
|
||||||
"react-router-dom": "^7.10.1",
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
|
"resend": "^6.12.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import http from "./http";
|
import http from "./http";
|
||||||
import type { DashboardData, DashboardFilters, DashboardResponse } from "../types/analytics.types";
|
import type {
|
||||||
|
DashboardData,
|
||||||
|
DashboardFilters,
|
||||||
|
DashboardUsers,
|
||||||
|
DateCount,
|
||||||
|
LabelCount,
|
||||||
|
} from "../types/analytics.types";
|
||||||
|
|
||||||
function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
|
function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
|
||||||
if (!filters || filters.mode === "all_time") {
|
if (!filters || filters.mode === "all_time") {
|
||||||
|
|
@ -21,19 +27,150 @@ function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, s
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapDashboardResponse(body: DashboardResponse | DashboardData): DashboardData {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
if (body && typeof body === "object" && "data" in body && body.data) {
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
return body.data;
|
|
||||||
}
|
}
|
||||||
return body as DashboardData;
|
|
||||||
|
function pickField(record: Record<string, unknown>, ...keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key in record && record[key] != null) return record[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asLabelCounts(value: unknown): LabelCount[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
const label = String(pickField(item, "label", "Label") ?? "").trim();
|
||||||
|
const count = Number(pickField(item, "count", "Count") ?? 0);
|
||||||
|
if (!label) return null;
|
||||||
|
return { label, count: Number.isFinite(count) ? count : 0 };
|
||||||
|
})
|
||||||
|
.filter((row): row is LabelCount => row !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asDateCounts(value: unknown): DateCount[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((item) => {
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
const date = String(pickField(item, "date", "Date") ?? "");
|
||||||
|
const count = Number(pickField(item, "count", "Count") ?? 0);
|
||||||
|
if (!date) return null;
|
||||||
|
return { date, count: Number.isFinite(count) ? count : 0 };
|
||||||
|
})
|
||||||
|
.filter((row): row is DateCount => row !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDashboardPayload(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) return false;
|
||||||
|
return (
|
||||||
|
"generated_at" in value ||
|
||||||
|
"generatedAt" in value ||
|
||||||
|
"users" in value ||
|
||||||
|
"Users" in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unwrap `{ data }` / `{ Data }` envelopes until the dashboard object is found. */
|
||||||
|
function unwrapDashboardPayload(body: unknown): Record<string, unknown> {
|
||||||
|
let current: unknown = body;
|
||||||
|
|
||||||
|
for (let depth = 0; depth < 5; depth++) {
|
||||||
|
if (!isRecord(current)) break;
|
||||||
|
|
||||||
|
if (isDashboardPayload(current)) {
|
||||||
|
const nested = pickField(current, "data", "Data");
|
||||||
|
if (isRecord(nested) && isDashboardPayload(nested)) {
|
||||||
|
current = nested;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = pickField(current, "data", "Data");
|
||||||
|
if (inner != null) {
|
||||||
|
current = inner;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isRecord(current) ? current : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDUCATION_LEVEL_KEYS = ["by_education_level", "byEducationLevel", "ByEducationLevel"] as const;
|
||||||
|
const OCCUPATION_KEYS = ["by_occupation", "byOccupation", "ByOccupation"] as const;
|
||||||
|
const LEARNING_GOAL_KEYS = ["by_learning_goal", "byLearningGoal", "ByLearningGoal"] as const;
|
||||||
|
const LANGUAGE_CHALLENGE_KEYS = [
|
||||||
|
"by_language_challange",
|
||||||
|
"by_language_challenge",
|
||||||
|
"byLanguageChallange",
|
||||||
|
"byLanguageChallenge",
|
||||||
|
"ByLanguageChallange",
|
||||||
|
"ByLanguageChallenge",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function normalizeDashboardUsers(raw: unknown, root?: Record<string, unknown>): DashboardUsers {
|
||||||
|
const u = isRecord(raw) ? raw : {};
|
||||||
|
const scope = root ?? u;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_users: Number(pickField(u, "total_users", "totalUsers", "TotalUsers") ?? 0),
|
||||||
|
new_today: Number(pickField(u, "new_today", "newToday", "NewToday") ?? 0),
|
||||||
|
new_week: Number(pickField(u, "new_week", "newWeek", "NewWeek") ?? 0),
|
||||||
|
new_month: Number(pickField(u, "new_month", "newMonth", "NewMonth") ?? 0),
|
||||||
|
by_role: asLabelCounts(pickField(u, "by_role", "byRole", "ByRole")),
|
||||||
|
by_status: asLabelCounts(pickField(u, "by_status", "byStatus", "ByStatus")),
|
||||||
|
by_age_group: asLabelCounts(pickField(u, "by_age_group", "byAgeGroup", "ByAgeGroup")),
|
||||||
|
by_education_level: asLabelCounts(
|
||||||
|
pickField(u, ...EDUCATION_LEVEL_KEYS) ?? pickField(scope, ...EDUCATION_LEVEL_KEYS),
|
||||||
|
),
|
||||||
|
by_occupation: asLabelCounts(
|
||||||
|
pickField(u, ...OCCUPATION_KEYS) ?? pickField(scope, ...OCCUPATION_KEYS),
|
||||||
|
),
|
||||||
|
by_learning_goal: asLabelCounts(
|
||||||
|
pickField(u, ...LEARNING_GOAL_KEYS) ?? pickField(scope, ...LEARNING_GOAL_KEYS),
|
||||||
|
),
|
||||||
|
by_language_challange: asLabelCounts(
|
||||||
|
pickField(u, ...LANGUAGE_CHALLENGE_KEYS) ?? pickField(scope, ...LANGUAGE_CHALLENGE_KEYS),
|
||||||
|
),
|
||||||
|
by_knowledge_level: asLabelCounts(
|
||||||
|
pickField(u, "by_knowledge_level", "byKnowledgeLevel", "ByKnowledgeLevel"),
|
||||||
|
),
|
||||||
|
by_region: asLabelCounts(pickField(u, "by_region", "byRegion", "ByRegion")),
|
||||||
|
registrations_last_30_days: asDateCounts(
|
||||||
|
pickField(
|
||||||
|
u,
|
||||||
|
"registrations_last_30_days",
|
||||||
|
"registrationsLast30Days",
|
||||||
|
"RegistrationsLast30Days",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardResponse(body: unknown): DashboardData {
|
||||||
|
const root = unwrapDashboardPayload(body);
|
||||||
|
const usersRaw = pickField(root, "users", "Users");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(root as DashboardData),
|
||||||
|
users: normalizeDashboardUsers(usersRaw, root),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDashboard = (filters?: DashboardFilters) =>
|
export const getDashboard = (filters?: DashboardFilters) =>
|
||||||
http
|
http
|
||||||
.get<DashboardResponse | DashboardData>("/analytics/dashboard", {
|
.get<unknown>("/analytics/dashboard", {
|
||||||
params: buildDashboardQueryParams(filters),
|
params: buildDashboardQueryParams(filters),
|
||||||
})
|
})
|
||||||
.then((res) => ({
|
.then((res) => ({
|
||||||
...res,
|
...res,
|
||||||
data: unwrapDashboardResponse(res.data),
|
data: normalizeDashboardResponse(res.data),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
69
src/api/emailTemplates.api.ts
Normal file
69
src/api/emailTemplates.api.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import http from "./http"
|
||||||
|
import type {
|
||||||
|
CreateEmailTemplateRequest,
|
||||||
|
CreateEmailTemplateResponse,
|
||||||
|
DeleteEmailTemplateResponse,
|
||||||
|
EmailTemplate,
|
||||||
|
GetEmailTemplateBySlugResponse,
|
||||||
|
GetEmailTemplatesResponse,
|
||||||
|
UpdateEmailTemplateRequest,
|
||||||
|
UpdateEmailTemplateResponse,
|
||||||
|
} from "../types/emailTemplate.types"
|
||||||
|
|
||||||
|
/** GET /admin/email-templates — list all email templates. */
|
||||||
|
export const getEmailTemplates = () =>
|
||||||
|
http.get<GetEmailTemplatesResponse>("/admin/email-templates")
|
||||||
|
|
||||||
|
/** GET /admin/email-templates/slug/:slug — single template by slug. */
|
||||||
|
export const getEmailTemplateBySlug = (slug: string) =>
|
||||||
|
http.get<GetEmailTemplateBySlugResponse>(
|
||||||
|
`/admin/email-templates/slug/${encodeURIComponent(slug)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
function normalizeEmailTemplate(row: unknown): EmailTemplate | null {
|
||||||
|
if (!row || typeof row !== "object" || !("slug" in row)) return null
|
||||||
|
const t = row as EmailTemplate
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
variables: Array.isArray(t.variables) ? t.variables : [],
|
||||||
|
status: t.status ?? "ACTIVE",
|
||||||
|
updated_at: t.updated_at ?? t.created_at ?? "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEmailTemplatesResponse(
|
||||||
|
response: Awaited<ReturnType<typeof getEmailTemplates>>,
|
||||||
|
): EmailTemplate[] {
|
||||||
|
const data = response.data?.data
|
||||||
|
const rows = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: Array.isArray(data?.templates)
|
||||||
|
? data.templates
|
||||||
|
: []
|
||||||
|
return rows
|
||||||
|
.map(normalizeEmailTemplate)
|
||||||
|
.filter((row): row is EmailTemplate => row != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /admin/email-templates/:id — update subject and bodies. */
|
||||||
|
export const updateEmailTemplate = (
|
||||||
|
id: number,
|
||||||
|
data: UpdateEmailTemplateRequest,
|
||||||
|
) => http.put<UpdateEmailTemplateResponse>(`/admin/email-templates/${id}`, data)
|
||||||
|
|
||||||
|
/** POST /admin/email-templates — create a custom template. */
|
||||||
|
export const createEmailTemplate = (data: CreateEmailTemplateRequest) =>
|
||||||
|
http.post<CreateEmailTemplateResponse>("/admin/email-templates", data)
|
||||||
|
|
||||||
|
/** DELETE /admin/email-templates/:id — delete a custom template. */
|
||||||
|
export const deleteEmailTemplate = (id: number) =>
|
||||||
|
http.delete<DeleteEmailTemplateResponse>(`/admin/email-templates/${id}`)
|
||||||
|
|
||||||
|
export function parseEmailTemplateResponse(
|
||||||
|
response:
|
||||||
|
| Awaited<ReturnType<typeof getEmailTemplateBySlug>>
|
||||||
|
| Awaited<ReturnType<typeof updateEmailTemplate>>
|
||||||
|
| Awaited<ReturnType<typeof createEmailTemplate>>,
|
||||||
|
): EmailTemplate | null {
|
||||||
|
return normalizeEmailTemplate(response.data?.data)
|
||||||
|
}
|
||||||
|
|
@ -59,7 +59,9 @@ const isAuthEndpointRequest = (url?: string) => {
|
||||||
return (
|
return (
|
||||||
url.includes("/team/login") ||
|
url.includes("/team/login") ||
|
||||||
url.includes("/team/google-login") ||
|
url.includes("/team/google-login") ||
|
||||||
url.includes("/team/refresh")
|
url.includes("/team/refresh") ||
|
||||||
|
url.includes("/team/invitations/verify") ||
|
||||||
|
url.includes("/team/invitations/accept")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import http from "./http"
|
import http from "./http"
|
||||||
|
import type {
|
||||||
|
AcceptInvitationRequest,
|
||||||
|
AcceptInvitationResponse,
|
||||||
|
InviteTeamMemberRequest,
|
||||||
|
InviteTeamMemberResponse,
|
||||||
|
VerifyInvitationResponse,
|
||||||
|
} from "../types/teamInvitation.types"
|
||||||
import type {
|
import type {
|
||||||
GetTeamMembersResponse,
|
GetTeamMembersResponse,
|
||||||
GetTeamMemberResponse,
|
GetTeamMemberResponse,
|
||||||
|
|
@ -25,3 +32,27 @@ export const updateTeamMemberStatus = (id: number, status: string) =>
|
||||||
|
|
||||||
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
||||||
http.put(`/team/members/${id}`, data)
|
http.put(`/team/members/${id}`, data)
|
||||||
|
|
||||||
|
/** POST /team/members/invite — send invitation email (permission: team.members.invite). */
|
||||||
|
export const inviteTeamMember = (data: InviteTeamMemberRequest) =>
|
||||||
|
http.post<InviteTeamMemberResponse>("/team/members/invite", data)
|
||||||
|
|
||||||
|
/** GET /team/invitations/verify?token= — public (accept-invite page). */
|
||||||
|
export const verifyTeamInvitation = (token: string) =>
|
||||||
|
http.get<VerifyInvitationResponse>("/team/invitations/verify", {
|
||||||
|
params: { token },
|
||||||
|
})
|
||||||
|
|
||||||
|
/** POST /team/invitations/accept — public (set password after invite). */
|
||||||
|
export const acceptTeamInvitation = (data: AcceptInvitationRequest) =>
|
||||||
|
http.post<AcceptInvitationResponse>("/team/invitations/accept", data)
|
||||||
|
|
||||||
|
export function parseVerifyInvitation(
|
||||||
|
response: Awaited<ReturnType<typeof verifyTeamInvitation>>,
|
||||||
|
): VerifyInvitationResponse["data"] | null {
|
||||||
|
const body = response.data
|
||||||
|
if (body?.data && typeof body.data === "object" && "valid" in body.data) {
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuesti
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
||||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
||||||
|
import { EmailTemplatesPage } from "../pages/notifications/EmailTemplatesPage";
|
||||||
|
import { EmailTemplateDetailPage } from "../pages/notifications/EmailTemplateDetailPage";
|
||||||
|
import { CreateEmailTemplatePage } from "../pages/notifications/CreateEmailTemplatePage";
|
||||||
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
import { UserDetailPage } from "../pages/user-management/UserDetailPage";
|
||||||
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
|
||||||
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
import { UsersListPage } from "../pages/user-management/UsersListPage";
|
||||||
|
|
@ -59,6 +62,7 @@ import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage";
|
||||||
import { LoginPage } from "../pages/auth/LoginPage";
|
import { LoginPage } from "../pages/auth/LoginPage";
|
||||||
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
|
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
|
||||||
import { VerificationPage } from "../pages/auth/VerificationPage";
|
import { VerificationPage } from "../pages/auth/VerificationPage";
|
||||||
|
import { AcceptInvitePage } from "../pages/auth/AcceptInvitePage";
|
||||||
import { AboutPage } from "../pages/AboutPage";
|
import { AboutPage } from "../pages/AboutPage";
|
||||||
import { TermsPage } from "../pages/TermsPage";
|
import { TermsPage } from "../pages/TermsPage";
|
||||||
import { PrivacyPage } from "../pages/PrivacyPage";
|
import { PrivacyPage } from "../pages/PrivacyPage";
|
||||||
|
|
@ -70,6 +74,7 @@ export function AppRoutes() {
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/verification" element={<VerificationPage />} />
|
<Route path="/verification" element={<VerificationPage />} />
|
||||||
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
<Route path="/terms" element={<TermsPage />} />
|
<Route path="/terms" element={<TermsPage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPage />} />
|
<Route path="/privacy" element={<PrivacyPage />} />
|
||||||
|
|
@ -234,6 +239,18 @@ export function AppRoutes() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/notifications" element={<NotificationsPage />} />
|
<Route path="/notifications" element={<NotificationsPage />} />
|
||||||
|
<Route
|
||||||
|
path="/notifications/email-templates"
|
||||||
|
element={<EmailTemplatesPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notifications/email-templates/new"
|
||||||
|
element={<CreateEmailTemplatePage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notifications/email-templates/:slug"
|
||||||
|
element={<EmailTemplateDetailPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/notifications/create"
|
path="/notifications/create"
|
||||||
element={<CreateNotificationPage />}
|
element={<CreateNotificationPage />}
|
||||||
|
|
|
||||||
|
|
@ -20,27 +20,47 @@ import { NavLink } from "react-router-dom";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { BrandLogo } from "../brand/BrandLogo";
|
import { BrandLogo } from "../brand/BrandLogo";
|
||||||
import { getUnreadCount } from "../../api/notifications.api";
|
import { getUnreadCount } from "../../api/notifications.api";
|
||||||
|
import { SidebarNavGroup } from "./SidebarNavGroup";
|
||||||
|
|
||||||
type NavItem = {
|
type NavLinkItem = {
|
||||||
|
kind: "link";
|
||||||
label: string;
|
label: string;
|
||||||
to: string;
|
to: string;
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
type NavGroupItem = {
|
||||||
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
kind: "group";
|
||||||
{ label: "User Management", to: "/users", icon: Users },
|
label: string;
|
||||||
{ label: "Role Management", to: "/roles", icon: Shield },
|
basePath: string;
|
||||||
{ label: "Content Management", to: "/content", icon: BookOpen },
|
icon: ComponentType<{ className?: string }>;
|
||||||
{ label: "New Content", to: "/new-content", icon: BookOpen },
|
children: { label: string; to: string; end?: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
{ label: "Notifications", to: "/notifications", icon: Bell },
|
type NavEntry = NavLinkItem | NavGroupItem;
|
||||||
{ label: "User Log", to: "/user-log", icon: ClipboardList },
|
|
||||||
{ label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
const navEntries: NavEntry[] = [
|
||||||
{ label: "Analytics", to: "/analytics", icon: BarChart3 },
|
{ kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||||
{ label: "Team Management", to: "/team", icon: Users2 },
|
{ kind: "link", label: "User Management", to: "/users", icon: Users },
|
||||||
{ label: "Profile", to: "/profile", icon: UserCircle2 },
|
{ kind: "link", label: "Role Management", to: "/roles", icon: Shield },
|
||||||
{ label: "Settings", to: "/settings", icon: Settings },
|
{ kind: "link", label: "Content Management", to: "/content", icon: BookOpen },
|
||||||
|
{ kind: "link", label: "New Content", to: "/new-content", icon: BookOpen },
|
||||||
|
{
|
||||||
|
kind: "group",
|
||||||
|
label: "Notifications",
|
||||||
|
basePath: "/notifications",
|
||||||
|
icon: Bell,
|
||||||
|
children: [
|
||||||
|
{ label: "My Notifications", to: "/notifications", end: true },
|
||||||
|
{ label: "Email Templates", to: "/notifications/email-templates" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: "link", label: "User Log", to: "/user-log", icon: ClipboardList },
|
||||||
|
{ kind: "link", label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
||||||
|
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||||
|
{ kind: "link", label: "Team Management", to: "/team", icon: Users2 },
|
||||||
|
{ kind: "link", label: "Profile", to: "/profile", icon: UserCircle2 },
|
||||||
|
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
|
|
@ -75,9 +95,18 @@ export function Sidebar({
|
||||||
window.removeEventListener("notifications-updated", fetchUnread);
|
window.removeEventListener("notifications-updated", fetchUnread);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const unreadBadge = unreadCount > 0 && (
|
||||||
|
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const collapsedUnreadDot = unreadCount > 0 && (
|
||||||
|
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile overlay */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
|
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
|
||||||
|
|
@ -87,7 +116,6 @@ export function Sidebar({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sidebar panel */}
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
|
"group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
|
||||||
|
|
@ -135,12 +163,27 @@ export function Sidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
||||||
{navItems.map((item) => {
|
{navEntries.map((entry) => {
|
||||||
const Icon = item.icon;
|
if (entry.kind === "group") {
|
||||||
|
return (
|
||||||
|
<SidebarNavGroup
|
||||||
|
key={entry.basePath}
|
||||||
|
label={entry.label}
|
||||||
|
icon={entry.icon}
|
||||||
|
basePath={entry.basePath}
|
||||||
|
children={entry.children}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onNavigate={onClose}
|
||||||
|
trailing={!isCollapsed ? unreadBadge : collapsedUnreadDot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = entry.icon;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={entry.to}
|
||||||
to={item.to}
|
to={entry.to}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
|
|
@ -151,41 +194,22 @@ export function Sidebar({
|
||||||
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={isCollapsed ? item.label : undefined}
|
title={isCollapsed ? entry.label : undefined}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
"grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
||||||
isActive && "bg-brand-500/90 text-white",
|
isActive && "bg-brand-500/90 text-white",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{isCollapsed &&
|
|
||||||
item.to === "/notifications" &&
|
|
||||||
unreadCount > 0 && (
|
|
||||||
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{entry.label}</span>
|
||||||
)}
|
)}
|
||||||
{!isCollapsed &&
|
{!isCollapsed && isActive ? (
|
||||||
item.to === "/notifications" &&
|
|
||||||
unreadCount > 0 && (
|
|
||||||
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
|
|
||||||
{unreadCount > 99 ? "99+" : unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isCollapsed &&
|
|
||||||
item.to !== "/notifications" &&
|
|
||||||
isActive ? (
|
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
|
||||||
) : !isCollapsed &&
|
|
||||||
item.to === "/notifications" &&
|
|
||||||
unreadCount === 0 &&
|
|
||||||
isActive ? (
|
|
||||||
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
135
src/components/sidebar/SidebarNavGroup.tsx
Normal file
135
src/components/sidebar/SidebarNavGroup.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { type ComponentType, type ReactNode, useEffect, useId, useState } from "react";
|
||||||
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export type SidebarNavChild = {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
end?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SidebarNavGroupProps = {
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
basePath: string;
|
||||||
|
children: SidebarNavChild[];
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SidebarNavGroup({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
basePath,
|
||||||
|
children,
|
||||||
|
isCollapsed,
|
||||||
|
onNavigate,
|
||||||
|
trailing,
|
||||||
|
}: SidebarNavGroupProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const panelId = useId();
|
||||||
|
const isSectionActive = location.pathname.startsWith(basePath);
|
||||||
|
const [expanded, setExpanded] = useState(isSectionActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSectionActive) {
|
||||||
|
setExpanded(true);
|
||||||
|
}
|
||||||
|
}, [isSectionActive]);
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={children[0]?.to ?? basePath}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"group flex items-center justify-center rounded-lg px-2 py-2.5 text-sm font-medium text-grayScale-600 transition",
|
||||||
|
"hover:bg-grayScale-100 hover:text-brand-600",
|
||||||
|
isActive &&
|
||||||
|
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
||||||
|
isActive && "bg-brand-500/90 text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{trailing}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={panelId}
|
||||||
|
onClick={() => setExpanded((open) => !open)}
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-grayScale-600 transition",
|
||||||
|
"hover:bg-grayScale-100 hover:text-brand-600",
|
||||||
|
isSectionActive && "text-brand-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
|
||||||
|
isSectionActive && "bg-brand-500/90 text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{trailing}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 text-grayScale-400 transition-transform duration-300 ease-in-out",
|
||||||
|
expanded && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
className={cn(
|
||||||
|
"grid transition-[grid-template-rows] duration-300 ease-in-out",
|
||||||
|
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="ml-4 space-y-0.5 border-l border-grayScale-200 pl-2 pt-0.5 pb-0.5">
|
||||||
|
{children.map((child) => (
|
||||||
|
<NavLink
|
||||||
|
key={child.to}
|
||||||
|
to={child.to}
|
||||||
|
end={child.end ?? false}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"block rounded-lg px-3 py-2 text-sm font-medium transition",
|
||||||
|
isActive
|
||||||
|
? "bg-brand-100/40 text-brand-600"
|
||||||
|
: "text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,50 @@
|
||||||
import type { DashboardDateFilter, DateRevenue, LabelCount } from "../types/analytics.types"
|
import type {
|
||||||
|
DashboardDateFilter,
|
||||||
|
DashboardSubscriptions,
|
||||||
|
DateRevenue,
|
||||||
|
LabelCount,
|
||||||
|
} from "../types/analytics.types"
|
||||||
|
|
||||||
|
const INACTIVE_SUBSCRIPTION_STATUSES = new Set([
|
||||||
|
"INACTIVE",
|
||||||
|
"CANCELLED",
|
||||||
|
"CANCELED",
|
||||||
|
"EXPIRED",
|
||||||
|
"PAUSED",
|
||||||
|
"SUSPENDED",
|
||||||
|
])
|
||||||
|
|
||||||
|
export interface SubscriptionMetrics {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
inactive: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives inactive count from by_status when present, else total − active. */
|
||||||
|
export function getSubscriptionMetrics(
|
||||||
|
subscriptions: DashboardSubscriptions,
|
||||||
|
): SubscriptionMetrics {
|
||||||
|
const total = subscriptions.total_subscriptions ?? 0
|
||||||
|
const active = subscriptions.active_subscriptions ?? 0
|
||||||
|
|
||||||
|
let inactiveFromStatus = 0
|
||||||
|
if (subscriptions.by_status?.length) {
|
||||||
|
inactiveFromStatus = subscriptions.by_status
|
||||||
|
.filter((s) => INACTIVE_SUBSCRIPTION_STATUSES.has(s.label.toUpperCase()))
|
||||||
|
.reduce((sum, s) => sum + s.count, 0)
|
||||||
|
|
||||||
|
if (inactiveFromStatus === 0) {
|
||||||
|
inactiveFromStatus = subscriptions.by_status
|
||||||
|
.filter((s) => s.label.toUpperCase() !== "ACTIVE")
|
||||||
|
.reduce((sum, s) => sum + s.count, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inactive =
|
||||||
|
inactiveFromStatus > 0 ? inactiveFromStatus : Math.max(0, total - active)
|
||||||
|
|
||||||
|
return { total, active, inactive }
|
||||||
|
}
|
||||||
|
|
||||||
const MONTH_SHORT = [
|
const MONTH_SHORT = [
|
||||||
"Jan",
|
"Jan",
|
||||||
|
|
@ -84,3 +130,11 @@ export function getSeriesPeriodLabel(dateFilter?: DashboardDateFilter): string {
|
||||||
return "Selected period"
|
return "Selected period"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Display label for dashboard breakdown rows (regions, enums, free text). */
|
||||||
|
export function formatAnalyticsLabel(label: string): string {
|
||||||
|
const text = label?.trim() ?? ""
|
||||||
|
if (!text || text.toLowerCase() === "unknown") return "Unknown"
|
||||||
|
if (text.includes("_")) return text.replace(/_/g, " ")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
|
||||||
56
src/lib/emailTemplatePreview.ts
Normal file
56
src/lib/emailTemplatePreview.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
const SAMPLE_VALUES: Record<string, string> = {
|
||||||
|
OTP: "123456",
|
||||||
|
FirstName: "Alex",
|
||||||
|
ExpiresMinutes: "10",
|
||||||
|
ResetLink: "https://app.yimaruacademy.com/reset?token=sample",
|
||||||
|
InviteLink: "https://app.yimaruacademy.com/invite?token=sample",
|
||||||
|
InviterName: "Jordan Admin",
|
||||||
|
LoginURL: "https://app.yimaruacademy.com/login",
|
||||||
|
Subject: "Sample announcement subject",
|
||||||
|
Message:
|
||||||
|
"This is sample body text shown in the admin preview. Replace variables when sending real emails.",
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleForVariable(name: string) {
|
||||||
|
return SAMPLE_VALUES[name] ?? `[${name}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort preview: substitutes `{{.Var}}` and unwraps simple `{{if .Var}}...{{end}}` blocks. */
|
||||||
|
export function renderEmailTemplatePreview(
|
||||||
|
source: string,
|
||||||
|
variables: string[],
|
||||||
|
): string {
|
||||||
|
let result = source
|
||||||
|
for (const variable of variables) {
|
||||||
|
const sample = sampleForVariable(variable)
|
||||||
|
result = result.split(`{{.${variable}}}`).join(sample)
|
||||||
|
const ifBlock = new RegExp(
|
||||||
|
`\\{\\{if \\.${variable}\\}\\}([\\s\\S]*?)\\{\\{end\\}\\}`,
|
||||||
|
"g",
|
||||||
|
)
|
||||||
|
result = result.replace(ifBlock, "$1")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEmailTemplateDate(raw: string | null | undefined) {
|
||||||
|
if (raw == null || String(raw).trim() === "") {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
const text = String(raw)
|
||||||
|
const parsed = new Date(text)
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return text.split(" +")[0]?.trim() || text
|
||||||
|
}
|
||||||
|
return parsed.toLocaleString(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailTemplateStatusBadgeVariant(status: string) {
|
||||||
|
const normalized = status.toUpperCase()
|
||||||
|
if (normalized === "ACTIVE") return "success" as const
|
||||||
|
if (normalized === "INACTIVE") return "secondary" as const
|
||||||
|
return "info" as const
|
||||||
|
}
|
||||||
27
src/lib/parseInviteEmails.ts
Normal file
27
src/lib/parseInviteEmails.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
/** Parse one or more emails from newline-, comma-, or semicolon-separated input. */
|
||||||
|
export function parseInviteEmails(text: string): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: string[] = []
|
||||||
|
|
||||||
|
for (const part of text.split(/[\n,;]+/)) {
|
||||||
|
const email = part.trim().toLowerCase()
|
||||||
|
if (!email || seen.has(email)) continue
|
||||||
|
seen.add(email)
|
||||||
|
result.push(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidInviteEmail(email: string): boolean {
|
||||||
|
return EMAIL_PATTERN.test(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InviteEmailSendResult = {
|
||||||
|
email: string
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
invitationId?: number
|
||||||
|
}
|
||||||
44
src/lib/teamInvitation.ts
Normal file
44
src/lib/teamInvitation.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { VerifyInvitationData } from "../types/teamInvitation.types"
|
||||||
|
|
||||||
|
export function formatTeamRoleLabel(role: string | undefined): string {
|
||||||
|
if (!role) return "—"
|
||||||
|
return role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInvitationExpiry(raw: string | undefined): string | null {
|
||||||
|
if (!raw) return null
|
||||||
|
const d = new Date(raw)
|
||||||
|
if (Number.isNaN(d.getTime())) return raw
|
||||||
|
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-facing title when verify returns valid: false. */
|
||||||
|
export function getInvalidInvitationTitle(data: VerifyInvitationData | null): string {
|
||||||
|
const status = data?.status?.toLowerCase() ?? ""
|
||||||
|
const message = (data?.message ?? "").toLowerCase()
|
||||||
|
|
||||||
|
if (status === "expired" || message.includes("expir")) {
|
||||||
|
return "This invitation has expired"
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status === "accepted" ||
|
||||||
|
message.includes("already") ||
|
||||||
|
message.includes("used") ||
|
||||||
|
message.includes("accepted")
|
||||||
|
) {
|
||||||
|
return "This invitation was already used"
|
||||||
|
}
|
||||||
|
if (status === "revoked" || message.includes("revok")) {
|
||||||
|
return "This invitation was revoked"
|
||||||
|
}
|
||||||
|
return "This invitation link is invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInvalidInvitationDescription(
|
||||||
|
data: VerifyInvitationData | null,
|
||||||
|
apiMessage?: string,
|
||||||
|
): string {
|
||||||
|
const specific = data?.message?.trim() || apiMessage?.trim()
|
||||||
|
if (specific) return specific
|
||||||
|
return "The link may be expired, invalid, or already used. Ask your administrator to send a new invitation."
|
||||||
|
}
|
||||||
43
src/lib/teamRoles.ts
Normal file
43
src/lib/teamRoles.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { Role } from "../types/rbac.types"
|
||||||
|
|
||||||
|
export const TEAM_ROLE_OPTIONS = [
|
||||||
|
{ value: "SUPER_ADMIN", label: "Super Admin" },
|
||||||
|
{ value: "ADMIN", label: "Admin" },
|
||||||
|
{ value: "CONTENT_MANAGER", label: "Content Manager" },
|
||||||
|
{ value: "SUPPORT_AGENT", label: "Support Agent" },
|
||||||
|
{ value: "INSTRUCTOR", label: "Instructor" },
|
||||||
|
{ value: "FINANCE", label: "Finance" },
|
||||||
|
{ value: "HR", label: "HR" },
|
||||||
|
{ value: "ANALYST", label: "Analyst" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const EMPLOYMENT_TYPE_OPTIONS = [
|
||||||
|
{ value: "full_time", label: "Full-time" },
|
||||||
|
{ value: "part_time", label: "Part-time" },
|
||||||
|
{ value: "contractor", label: "Contractor" },
|
||||||
|
{ value: "intern", label: "Intern" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Map RBAC role display name to API team_role (e.g. CONTENT_MANAGER). */
|
||||||
|
export function rbacRoleNameToTeamRole(roleName: string): string {
|
||||||
|
const normalized = roleName.trim().toUpperCase().replace(/[\s-]+/g, "_")
|
||||||
|
const byValue = TEAM_ROLE_OPTIONS.find((o) => o.value === normalized)
|
||||||
|
if (byValue) return byValue.value
|
||||||
|
const byLabel = TEAM_ROLE_OPTIONS.find(
|
||||||
|
(o) => o.label.toUpperCase().replace(/[\s-]+/g, "_") === normalized,
|
||||||
|
)
|
||||||
|
if (byLabel) return byLabel.value
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teamRoleFromRbacRole(role: Role): string {
|
||||||
|
return rbacRoleNameToTeamRole(role.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTeamRoleLabel(teamRole: string): string {
|
||||||
|
const found = TEAM_ROLE_OPTIONS.find(
|
||||||
|
(o) => o.value === teamRole || o.value === teamRole.toUpperCase(),
|
||||||
|
)
|
||||||
|
if (found) return found.label
|
||||||
|
return teamRole.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
// TrendingUp,
|
// TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
|
UserX,
|
||||||
Bell,
|
Bell,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
|
|
@ -39,7 +40,12 @@ import { getSubscriptionPlans } from "../api/subscription-plans.api"
|
||||||
import { getRatings } from "../api/courses.api"
|
import { getRatings } from "../api/courses.api"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
|
import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
|
||||||
import { getPrimaryQuestionTypeSummary, getSeriesPeriodLabel, getVideoLessonsSummary } from "../lib/analytics"
|
import {
|
||||||
|
getPrimaryQuestionTypeSummary,
|
||||||
|
getSeriesPeriodLabel,
|
||||||
|
getSubscriptionMetrics,
|
||||||
|
getVideoLessonsSummary,
|
||||||
|
} from "../lib/analytics"
|
||||||
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
|
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
|
||||||
import type { SubscriptionPlan } from "../types/subscription.types"
|
import type { SubscriptionPlan } from "../types/subscription.types"
|
||||||
import type { Rating } from "../types/course.types"
|
import type { Rating } from "../types/course.types"
|
||||||
|
|
@ -164,6 +170,9 @@ export function DashboardPage() {
|
||||||
})) ?? []
|
})) ?? []
|
||||||
|
|
||||||
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
|
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
|
||||||
|
const subscriptionMetrics = dashboard
|
||||||
|
? getSubscriptionMetrics(dashboard.subscriptions)
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl">
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
|
@ -234,11 +243,11 @@ export function DashboardPage() {
|
||||||
deltaPositive={dashboard.users.new_month > 0}
|
deltaPositive={dashboard.users.new_month > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={BadgeCheck}
|
icon={CreditCard}
|
||||||
label="Active Subscribers"
|
label="Payments"
|
||||||
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
|
value={dashboard.payments.total_payments.toLocaleString()}
|
||||||
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
|
deltaLabel={`${dashboard.payments.successful_payments} successful`}
|
||||||
deltaPositive={dashboard.subscriptions.new_month > 0}
|
deltaPositive={dashboard.payments.successful_payments > 0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
|
|
@ -258,8 +267,33 @@ export function DashboardPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secondary Stats */}
|
{/* Secondary Stats */}
|
||||||
{activeStatTab === "secondary" && (
|
{activeStatTab === "secondary" && subscriptionMetrics && (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
icon={CreditCard}
|
||||||
|
label="Total Subscriptions"
|
||||||
|
value={subscriptionMetrics.total.toLocaleString()}
|
||||||
|
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
|
||||||
|
deltaPositive={dashboard.subscriptions.new_month > 0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={BadgeCheck}
|
||||||
|
label="Active Subscriptions"
|
||||||
|
value={subscriptionMetrics.active.toLocaleString()}
|
||||||
|
deltaLabel={`+${dashboard.subscriptions.new_today} today · +${dashboard.subscriptions.new_week} this week`}
|
||||||
|
deltaPositive={subscriptionMetrics.active > 0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={UserX}
|
||||||
|
label="Inactive Subscriptions"
|
||||||
|
value={subscriptionMetrics.inactive.toLocaleString()}
|
||||||
|
deltaLabel={
|
||||||
|
dashboard.subscriptions.by_status.length > 0
|
||||||
|
? "From subscription status breakdown"
|
||||||
|
: "Total minus active"
|
||||||
|
}
|
||||||
|
deltaPositive={subscriptionMetrics.inactive === 0}
|
||||||
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={Video}
|
icon={Video}
|
||||||
label="Videos"
|
label="Videos"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../compone
|
||||||
import {
|
import {
|
||||||
getPrimaryQuestionTypeSummary,
|
getPrimaryQuestionTypeSummary,
|
||||||
getSeriesPeriodLabel,
|
getSeriesPeriodLabel,
|
||||||
|
formatAnalyticsLabel,
|
||||||
|
getSubscriptionMetrics,
|
||||||
getVideoLessonsSummary,
|
getVideoLessonsSummary,
|
||||||
} from "../../lib/analytics"
|
} from "../../lib/analytics"
|
||||||
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
|
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
|
||||||
|
|
@ -115,31 +117,43 @@ function BreakdownList({
|
||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
total,
|
total,
|
||||||
|
scrollable,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
data: LabelCount[]
|
data: LabelCount[]
|
||||||
total?: number
|
total?: number
|
||||||
|
/** Enable vertical scroll for long breakdowns (e.g. occupation). */
|
||||||
|
scrollable?: boolean
|
||||||
}) {
|
}) {
|
||||||
const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0)
|
const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0)
|
||||||
|
const sorted = [...data].sort((a, b) => b.count - a.count)
|
||||||
return (
|
return (
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm">{title}</CardTitle>
|
<CardTitle className="text-sm">{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-0">
|
||||||
{data.length > 0 ? (
|
{sorted.length > 0 ? (
|
||||||
<div className="space-y-2.5">
|
<div
|
||||||
{data.map((item, i) => {
|
className={cn(
|
||||||
|
"space-y-2.5",
|
||||||
|
scrollable && "max-h-64 overflow-y-auto overscroll-contain pr-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sorted.map((item, i) => {
|
||||||
const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
|
const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
|
||||||
|
const displayLabel = formatAnalyticsLabel(item.label)
|
||||||
return (
|
return (
|
||||||
<div key={item.label}>
|
<div key={`${item.label}-${i}`}>
|
||||||
<div className="mb-1 flex items-center justify-between text-xs">
|
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
|
||||||
/>
|
/>
|
||||||
<span className="text-grayScale-600">{item.label}</span>
|
<span className="truncate text-grayScale-600" title={displayLabel}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-grayScale-700">
|
<span className="font-semibold text-grayScale-700">
|
||||||
{item.count.toLocaleString()}
|
{item.count.toLocaleString()}
|
||||||
|
|
@ -350,6 +364,7 @@ export function AnalyticsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
|
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
|
||||||
|
const subscriptionMetrics = getSubscriptionMetrics(subscriptions)
|
||||||
const seriesPeriodLabel = getSeriesPeriodLabel(dashboard.date_filter)
|
const seriesPeriodLabel = getSeriesPeriodLabel(dashboard.date_filter)
|
||||||
const lms = courses.lms
|
const lms = courses.lms
|
||||||
const examPrep = courses.exam_prep
|
const examPrep = courses.exam_prep
|
||||||
|
|
@ -478,10 +493,10 @@ export function AnalyticsPage() {
|
||||||
trend={users.new_month > 0 ? "up" : "neutral"}
|
trend={users.new_month > 0 ? "up" : "neutral"}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={BadgeCheck}
|
icon={CreditCard}
|
||||||
label="Active Subscriptions"
|
label="Total Subscriptions"
|
||||||
value={formatNumber(subscriptions.active_subscriptions)}
|
value={formatNumber(subscriptionMetrics.total)}
|
||||||
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
|
sub={`${subscriptionMetrics.active} active · ${subscriptionMetrics.inactive} inactive`}
|
||||||
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
|
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
|
|
@ -628,12 +643,72 @@ export function AnalyticsPage() {
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="mt-4 space-y-6">
|
||||||
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
|
<div>
|
||||||
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
|
||||||
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
|
Profile & demographics
|
||||||
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} />
|
</p>
|
||||||
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} />
|
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<BreakdownList
|
||||||
|
title="Education level"
|
||||||
|
data={users.by_education_level ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
/>
|
||||||
|
<BreakdownList
|
||||||
|
title="Occupation"
|
||||||
|
data={users.by_occupation ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
scrollable
|
||||||
|
/>
|
||||||
|
<BreakdownList
|
||||||
|
title="Age group"
|
||||||
|
data={users.by_age_group ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
/>
|
||||||
|
<BreakdownList
|
||||||
|
title="Region"
|
||||||
|
data={users.by_region ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
scrollable
|
||||||
|
/>
|
||||||
|
<BreakdownList
|
||||||
|
title="Account role"
|
||||||
|
data={users.by_role ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
/>
|
||||||
|
<BreakdownList
|
||||||
|
title="Account status"
|
||||||
|
data={users.by_status ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
/>
|
||||||
|
{(users.by_knowledge_level?.length ?? 0) > 0 ? (
|
||||||
|
<BreakdownList
|
||||||
|
title="Knowledge level"
|
||||||
|
data={users.by_knowledge_level ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
|
||||||
|
Learning goals & challenges
|
||||||
|
</p>
|
||||||
|
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<BreakdownList
|
||||||
|
title="Learning goal"
|
||||||
|
data={users.by_learning_goal ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
scrollable
|
||||||
|
/>
|
||||||
|
<BreakdownList
|
||||||
|
title="Language challenge"
|
||||||
|
data={users.by_language_challange ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
scrollable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
|
||||||
467
src/pages/auth/AcceptInvitePage.tsx
Normal file
467
src/pages/auth/AcceptInvitePage.tsx
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Briefcase,
|
||||||
|
Building2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
acceptTeamInvitation,
|
||||||
|
parseVerifyInvitation,
|
||||||
|
verifyTeamInvitation,
|
||||||
|
} from "../../api/team.api"
|
||||||
|
import { BrandLogo } from "../../components/brand/BrandLogo"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import {
|
||||||
|
formatInvitationExpiry,
|
||||||
|
formatTeamRoleLabel,
|
||||||
|
getInvalidInvitationDescription,
|
||||||
|
getInvalidInvitationTitle,
|
||||||
|
} from "../../lib/teamInvitation"
|
||||||
|
import type { VerifyInvitationData } from "../../types/teamInvitation.types"
|
||||||
|
|
||||||
|
export function AcceptInvitePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const token = searchParams.get("token")?.trim() ?? ""
|
||||||
|
|
||||||
|
const [verifyState, setVerifyState] = useState<
|
||||||
|
"loading" | "invalid" | "ready" | "success"
|
||||||
|
>("loading")
|
||||||
|
const [inviteInfo, setInviteInfo] = useState<VerifyInvitationData | null>(null)
|
||||||
|
const [invalidTitle, setInvalidTitle] = useState("")
|
||||||
|
const [invalidDescription, setInvalidDescription] = useState("")
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("")
|
||||||
|
const [lastName, setLastName] = useState("")
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState("")
|
||||||
|
const [department, setDepartment] = useState("")
|
||||||
|
const [jobTitle, setJobTitle] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const loadVerification = useCallback(async () => {
|
||||||
|
if (!token) {
|
||||||
|
setInviteInfo(null)
|
||||||
|
setInvalidTitle("This invitation link is invalid")
|
||||||
|
setInvalidDescription("Invitation link is missing a token.")
|
||||||
|
setVerifyState("invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifyState("loading")
|
||||||
|
setInviteInfo(null)
|
||||||
|
try {
|
||||||
|
const res = await verifyTeamInvitation(token)
|
||||||
|
const data = parseVerifyInvitation(res)
|
||||||
|
|
||||||
|
if (!data || data.valid !== true) {
|
||||||
|
setInviteInfo(data)
|
||||||
|
setInvalidTitle(getInvalidInvitationTitle(data))
|
||||||
|
setInvalidDescription(
|
||||||
|
getInvalidInvitationDescription(data, res.data?.message),
|
||||||
|
)
|
||||||
|
setVerifyState("invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setInviteInfo(data)
|
||||||
|
setFirstName(data.first_name?.trim() ?? "")
|
||||||
|
setLastName(data.last_name?.trim() ?? "")
|
||||||
|
setVerifyState("ready")
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setInviteInfo(null)
|
||||||
|
setInvalidTitle("This invitation link is invalid")
|
||||||
|
setInvalidDescription(
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ??
|
||||||
|
"The link may be expired, invalid, or already used. Ask your administrator to send a new invitation.",
|
||||||
|
)
|
||||||
|
setVerifyState("invalid")
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadVerification()
|
||||||
|
}, [loadVerification])
|
||||||
|
|
||||||
|
const handleAccept = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
if (!firstName.trim() || !lastName.trim()) {
|
||||||
|
toast.error("First name and last name are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
toast.error("Password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
toast.error("Passwords do not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await acceptTeamInvitation({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
first_name: firstName.trim(),
|
||||||
|
last_name: lastName.trim(),
|
||||||
|
phone_number: phoneNumber.trim(),
|
||||||
|
department: department.trim(),
|
||||||
|
job_title: jobTitle.trim(),
|
||||||
|
})
|
||||||
|
setVerifyState("success")
|
||||||
|
toast.success(res.data?.message ?? "Account setup complete. You can sign in now.")
|
||||||
|
navigate("/login", { replace: true })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to complete setup"
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryLabel = formatInvitationExpiry(inviteInfo?.expires_at)
|
||||||
|
const setupTitle = inviteInfo?.needs_profile_setup
|
||||||
|
? "Complete your account setup"
|
||||||
|
: "Set your password"
|
||||||
|
|
||||||
|
if (localStorage.getItem("access_token")) {
|
||||||
|
return <Navigate to="/dashboard" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen overflow-hidden">
|
||||||
|
<div className="relative hidden items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 lg:flex lg:w-1/2 xl:w-[55%]">
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
|
||||||
|
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10 max-w-md px-12 text-center">
|
||||||
|
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
|
||||||
|
<p className="text-base leading-relaxed text-white/70">
|
||||||
|
You have been invited to join the Yimaru admin panel. Verify your invitation,
|
||||||
|
then complete setup to activate your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-screen min-h-0 w-full flex-col overflow-hidden bg-white px-6 py-6 lg:w-1/2 lg:py-8 xl:w-[45%]">
|
||||||
|
<div className="mx-auto flex h-full min-h-0 w-full max-w-[440px] flex-col">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="mb-6 flex justify-center lg:hidden">
|
||||||
|
<BrandLogo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 lg:mb-6">
|
||||||
|
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
|
||||||
|
Team invitation
|
||||||
|
</p>
|
||||||
|
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
|
||||||
|
{verifyState === "success"
|
||||||
|
? "You're all set"
|
||||||
|
: verifyState === "invalid"
|
||||||
|
? invalidTitle
|
||||||
|
: "Accept invitation"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-grayScale-400">
|
||||||
|
{verifyState === "success"
|
||||||
|
? "Redirecting you to sign in…"
|
||||||
|
: verifyState === "invalid"
|
||||||
|
? invalidDescription
|
||||||
|
: verifyState === "ready"
|
||||||
|
? setupTitle
|
||||||
|
: "Verifying your invitation link…"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-h-0 flex-1",
|
||||||
|
verifyState === "ready" ? "overflow-y-auto overscroll-contain" : "flex flex-col justify-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{verifyState === "loading" && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-16">
|
||||||
|
<SpinnerIcon className="h-8 w-8" />
|
||||||
|
<p className="text-sm text-grayScale-400">Verifying invitation…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verifyState === "invalid" && (
|
||||||
|
<div className="space-y-4 rounded-xl border border-red-200 bg-red-50 px-4 py-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
|
||||||
|
<div className="space-y-2 text-sm text-red-800">
|
||||||
|
<p className="font-semibold">{invalidTitle}</p>
|
||||||
|
<p>{invalidDescription}</p>
|
||||||
|
<p className="text-xs text-red-700/90">
|
||||||
|
Common reasons: expired, invalid, or already used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{inviteInfo?.email ? (
|
||||||
|
<p className="border-t border-red-200/80 pt-3 text-xs text-red-700/80">
|
||||||
|
Invitation email: <span className="font-medium">{inviteInfo.email}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-red-200 bg-white"
|
||||||
|
onClick={() => void loadVerification()}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verifyState === "ready" && inviteInfo && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void handleAccept(e)}
|
||||||
|
className="space-y-5 pr-1 pb-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="invite-email"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="invite-email"
|
||||||
|
type="email"
|
||||||
|
readOnly
|
||||||
|
value={inviteInfo.email ?? ""}
|
||||||
|
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="invite-role"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="invite-role"
|
||||||
|
readOnly
|
||||||
|
value={formatTeamRoleLabel(inviteInfo.team_role)}
|
||||||
|
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expiryLabel ? (
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
Invitation expires {expiryLabel}
|
||||||
|
{inviteInfo.status ? ` · Status: ${inviteInfo.status}` : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="border-t border-grayScale-100 pt-4">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
|
||||||
|
Your details
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="first-name"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<User className="h-3.5 w-3.5" />
|
||||||
|
First name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="first-name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
placeholder="John"
|
||||||
|
autoComplete="given-name"
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="last-name"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<User className="h-3.5 w-3.5" />
|
||||||
|
Last name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="last-name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
placeholder="Doe"
|
||||||
|
autoComplete="family-name"
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="phone"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Phone className="h-3.5 w-3.5" />
|
||||||
|
Phone number
|
||||||
|
<span className="font-normal text-grayScale-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||||
|
placeholder="+251..."
|
||||||
|
autoComplete="tel"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="department"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
|
Department
|
||||||
|
<span className="font-normal text-grayScale-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="department"
|
||||||
|
value={department}
|
||||||
|
onChange={(e) => setDepartment(e.target.value)}
|
||||||
|
placeholder="e.g. LMS"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="job-title"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Briefcase className="h-3.5 w-3.5" />
|
||||||
|
Job title
|
||||||
|
<span className="font-normal text-grayScale-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="job-title"
|
||||||
|
value={jobTitle}
|
||||||
|
onChange={(e) => setJobTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Content Lead"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-grayScale-100 pt-4">
|
||||||
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
|
||||||
|
Account password
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
className="pr-10"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
Confirm password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Completing setup…" : "Complete account setup"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verifyState === "success" && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<SpinnerIcon className="h-6 w-6" />
|
||||||
|
<p className="text-sm text-grayScale-500">Taking you to sign in…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shrink-0 border-t border-grayScale-100 pt-4 text-center text-sm text-grayScale-400">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link to="/login" className="font-semibold text-brand-500 hover:text-brand-600">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
src/pages/notifications/CreateEmailTemplatePage.tsx
Normal file
158
src/pages/notifications/CreateEmailTemplatePage.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
createEmailTemplate,
|
||||||
|
parseEmailTemplateResponse,
|
||||||
|
} from "../../api/emailTemplates.api"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
import type { EmailTemplateStatus } from "../../types/emailTemplate.types"
|
||||||
|
import {
|
||||||
|
EmailTemplateCreateForm,
|
||||||
|
EMPTY_EMAIL_TEMPLATE_CREATE_DRAFT,
|
||||||
|
parseEmailTemplateVariables,
|
||||||
|
slugFromTemplateName,
|
||||||
|
type EmailTemplateCreateDraft,
|
||||||
|
} from "./components/EmailTemplateCreateForm"
|
||||||
|
import { EmailTemplatePreviewPanel } from "./components/EmailTemplatePreviewPanel"
|
||||||
|
|
||||||
|
const SLUG_PATTERN = /^[a-z][a-z0-9_]*$/
|
||||||
|
|
||||||
|
function buildCreatePayload(draft: EmailTemplateCreateDraft) {
|
||||||
|
return {
|
||||||
|
slug: draft.slug.trim(),
|
||||||
|
name: draft.name.trim(),
|
||||||
|
subject: draft.subject,
|
||||||
|
body_text: draft.body_text,
|
||||||
|
body_html: draft.body_html,
|
||||||
|
variables: parseEmailTemplateVariables(draft.variablesText),
|
||||||
|
status: draft.status.trim().toUpperCase() as EmailTemplateStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDraft(draft: EmailTemplateCreateDraft): string | null {
|
||||||
|
const slug = draft.slug.trim()
|
||||||
|
const name = draft.name.trim()
|
||||||
|
if (!name) return "Name is required"
|
||||||
|
if (!slug) return "Slug is required"
|
||||||
|
if (!SLUG_PATTERN.test(slug)) {
|
||||||
|
return "Slug must start with a letter and use only lowercase letters, numbers, and underscores"
|
||||||
|
}
|
||||||
|
if (!draft.subject.trim()) return "Subject is required"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateEmailTemplatePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<EmailTemplateCreateDraft>(
|
||||||
|
EMPTY_EMAIL_TEMPLATE_CREATE_DRAFT,
|
||||||
|
)
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false)
|
||||||
|
|
||||||
|
const previewSource = useMemo(() => {
|
||||||
|
const variables = parseEmailTemplateVariables(draft.variablesText)
|
||||||
|
return {
|
||||||
|
subject: draft.subject,
|
||||||
|
body_text: draft.body_text,
|
||||||
|
body_html: draft.body_html,
|
||||||
|
variables,
|
||||||
|
slug: draft.slug.trim() || undefined,
|
||||||
|
}
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
const handleChange = (patch: Partial<EmailTemplateCreateDraft>) => {
|
||||||
|
setDraft((prev) => {
|
||||||
|
const next = { ...prev, ...patch }
|
||||||
|
if ("slug" in patch) {
|
||||||
|
setSlugTouched(true)
|
||||||
|
}
|
||||||
|
if ("name" in patch && !slugTouched && patch.name != null) {
|
||||||
|
const autoSlug = slugFromTemplateName(patch.name)
|
||||||
|
if (autoSlug) next.slug = autoSlug
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const validationError = validateDraft(draft)
|
||||||
|
if (validationError) {
|
||||||
|
toast.error(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const response = await createEmailTemplate(buildCreatePayload(draft))
|
||||||
|
const created = parseEmailTemplateResponse(response)
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Empty create response")
|
||||||
|
}
|
||||||
|
toast.success(response.data?.message ?? "Email template created successfully")
|
||||||
|
navigate(`/notifications/email-templates/${created.slug}`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e)
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to create email template"
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-6 pb-12">
|
||||||
|
<Link
|
||||||
|
to="/notifications/email-templates"
|
||||||
|
className="group flex w-fit items-center gap-2 text-sm font-semibold text-grayScale-600 transition-colors hover:text-brand-500"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
Back to email templates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-grayScale-500">Email template</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-grayScale-900">
|
||||||
|
New custom template
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-sm text-grayScale-500">
|
||||||
|
Create a custom template via{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||||
|
POST /admin/email-templates
|
||||||
|
</code>
|
||||||
|
. System templates are managed separately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardHeader className="border-b border-grayScale-100 pb-4">
|
||||||
|
<CardTitle className="text-lg">Template details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<EmailTemplateCreateForm
|
||||||
|
draft={draft}
|
||||||
|
saving={saving}
|
||||||
|
onChange={handleChange}
|
||||||
|
onSubmit={() => void handleCreate()}
|
||||||
|
onReset={() => {
|
||||||
|
setDraft(EMPTY_EMAIL_TEMPLATE_CREATE_DRAFT)
|
||||||
|
setSlugTouched(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardHeader className="border-b border-grayScale-100 pb-4">
|
||||||
|
<CardTitle className="text-lg">Preview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<EmailTemplatePreviewPanel source={previewSource} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
233
src/pages/notifications/EmailTemplateDetailPage.tsx
Normal file
233
src/pages/notifications/EmailTemplateDetailPage.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { Link, useNavigate, useParams } from "react-router-dom"
|
||||||
|
import { ArrowLeft, RefreshCw, Shield, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
getEmailTemplateBySlug,
|
||||||
|
parseEmailTemplateResponse,
|
||||||
|
updateEmailTemplate,
|
||||||
|
} from "../../api/emailTemplates.api"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import {
|
||||||
|
emailTemplateStatusBadgeVariant,
|
||||||
|
formatEmailTemplateDate,
|
||||||
|
} from "../../lib/emailTemplatePreview"
|
||||||
|
import type { EmailTemplate } from "../../types/emailTemplate.types"
|
||||||
|
import {
|
||||||
|
EmailTemplateEditForm,
|
||||||
|
emailTemplateDraftFromTemplate,
|
||||||
|
type EmailTemplateDraft,
|
||||||
|
} from "./components/EmailTemplateEditForm"
|
||||||
|
import { EmailTemplateDeleteDialog } from "./components/EmailTemplateDeleteDialog"
|
||||||
|
import { EmailTemplatePreviewPanel } from "./components/EmailTemplatePreviewPanel"
|
||||||
|
|
||||||
|
function applyTemplateToDraft(template: EmailTemplate): EmailTemplateDraft {
|
||||||
|
return emailTemplateDraftFromTemplate(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailTemplateDetailPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [template, setTemplate] = useState<EmailTemplate | null>(null)
|
||||||
|
const [draft, setDraft] = useState<EmailTemplateDraft>({
|
||||||
|
subject: "",
|
||||||
|
body_text: "",
|
||||||
|
body_html: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const trimmed = slug?.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setError(true)
|
||||||
|
setTemplate(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
try {
|
||||||
|
const response = await getEmailTemplateBySlug(trimmed)
|
||||||
|
const row = parseEmailTemplateResponse(response)
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Empty template response")
|
||||||
|
}
|
||||||
|
setTemplate(row)
|
||||||
|
setDraft(applyTemplateToDraft(row))
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setError(true)
|
||||||
|
setTemplate(null)
|
||||||
|
toast.error("Failed to load email template")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const previewSource = useMemo(() => {
|
||||||
|
if (!template) return null
|
||||||
|
return {
|
||||||
|
subject: draft.subject,
|
||||||
|
body_text: draft.body_text,
|
||||||
|
body_html: draft.body_html,
|
||||||
|
variables: template.variables,
|
||||||
|
slug: template.slug,
|
||||||
|
}
|
||||||
|
}, [template, draft])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!template) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const response = await updateEmailTemplate(template.id, {
|
||||||
|
subject: draft.subject.trim(),
|
||||||
|
body_text: draft.body_text,
|
||||||
|
body_html: draft.body_html,
|
||||||
|
})
|
||||||
|
const updated = parseEmailTemplateResponse(response)
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error("Empty update response")
|
||||||
|
}
|
||||||
|
setTemplate(updated)
|
||||||
|
setDraft(applyTemplateToDraft(updated))
|
||||||
|
toast.success("Email template updated")
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e)
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to update email template"
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-6 pb-12">
|
||||||
|
<Link
|
||||||
|
to="/notifications/email-templates"
|
||||||
|
className="flex w-fit items-center gap-2 text-sm font-semibold text-grayScale-600 transition-colors hover:text-brand-500 group"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
Back to email templates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-20">
|
||||||
|
<SpinnerIcon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
) : error || !template || !previewSource ? (
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
Could not load template
|
||||||
|
{slug ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 text-xs">{slug}</code>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-semibold text-grayScale-500">Email template</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-grayScale-900">
|
||||||
|
{template.name}
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<code className="rounded bg-grayScale-100 px-2 py-0.5 text-sm text-grayScale-600">
|
||||||
|
{template.slug}
|
||||||
|
</code>
|
||||||
|
<Badge variant={emailTemplateStatusBadgeVariant(template.status)}>
|
||||||
|
{template.status}
|
||||||
|
</Badge>
|
||||||
|
{template.is_system ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
System
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
ID {template.id} · Updated {formatEmailTemplateDate(template.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn("mr-2 h-4 w-4", (loading || saving) && "animate-spin")}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{!template.is_system ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardHeader className="border-b border-grayScale-100 pb-4">
|
||||||
|
<CardTitle className="text-lg">Edit template</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<EmailTemplateEditForm
|
||||||
|
template={template}
|
||||||
|
draft={draft}
|
||||||
|
saving={saving}
|
||||||
|
onChange={(patch) => setDraft((prev) => ({ ...prev, ...patch }))}
|
||||||
|
onSave={() => void handleSave()}
|
||||||
|
onReset={() => setDraft(applyTemplateToDraft(template))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardHeader className="border-b border-grayScale-100 pb-4">
|
||||||
|
<CardTitle className="text-lg">Preview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
<EmailTemplatePreviewPanel source={previewSource} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<EmailTemplateDeleteDialog
|
||||||
|
template={template}
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
onDeleted={() => navigate("/notifications/email-templates")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
275
src/pages/notifications/EmailTemplatesPage.tsx
Normal file
275
src/pages/notifications/EmailTemplatesPage.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { Eye, Mail, Plus, RefreshCw, Search, Shield, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
getEmailTemplates,
|
||||||
|
parseEmailTemplatesResponse,
|
||||||
|
} from "../../api/emailTemplates.api"
|
||||||
|
import { Badge } from "../../components/ui/badge"
|
||||||
|
import { Button } from "../../components/ui/button"
|
||||||
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
|
import { Input } from "../../components/ui/input"
|
||||||
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
import {
|
||||||
|
emailTemplateStatusBadgeVariant,
|
||||||
|
formatEmailTemplateDate,
|
||||||
|
} from "../../lib/emailTemplatePreview"
|
||||||
|
import type { EmailTemplate } from "../../types/emailTemplate.types"
|
||||||
|
import { EmailTemplateDeleteDialog } from "./components/EmailTemplateDeleteDialog"
|
||||||
|
|
||||||
|
export function EmailTemplatesPage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [templates, setTemplates] = useState<EmailTemplate[]>([])
|
||||||
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"All" | "ACTIVE" | "INACTIVE">("All")
|
||||||
|
const [templatePendingDelete, setTemplatePendingDelete] = useState<EmailTemplate | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
try {
|
||||||
|
const response = await getEmailTemplates()
|
||||||
|
const rows = parseEmailTemplatesResponse(response)
|
||||||
|
setTemplates(rows)
|
||||||
|
setTotalCount(Number(response.data?.data?.total_count ?? rows.length))
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setError(true)
|
||||||
|
setTemplates([])
|
||||||
|
setTotalCount(0)
|
||||||
|
toast.error("Failed to load email templates")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
return templates.filter((t) => {
|
||||||
|
const status = String(t.status ?? "").toUpperCase()
|
||||||
|
if (statusFilter !== "All" && status !== statusFilter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!q) return true
|
||||||
|
const variables = Array.isArray(t.variables) ? t.variables : []
|
||||||
|
const haystack = [t.name, t.slug, t.subject, variables.join(" ")]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
return haystack.includes(q)
|
||||||
|
})
|
||||||
|
}, [templates, query, statusFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-grayScale-500">Notifications</p>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-grayScale-800">
|
||||||
|
Email Templates
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||||
|
Templates from{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||||
|
GET /admin/email-templates
|
||||||
|
</code>
|
||||||
|
. Open a template for full preview via slug API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button className="shrink-0 bg-brand-500 text-white hover:bg-brand-600" asChild>
|
||||||
|
<Link to="/notifications/email-templates/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New template
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardContent className="space-y-4 p-4 sm:p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
|
<Input
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="Search by name, slug, subject, or variable…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(tab)}
|
||||||
|
className={cn(
|
||||||
|
"h-9 rounded-full px-3 text-xs font-semibold transition-colors",
|
||||||
|
statusFilter === tab
|
||||||
|
? "bg-brand-500 text-white"
|
||||||
|
: "bg-grayScale-100 text-grayScale-600 hover:bg-grayScale-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-grayScale-500">
|
||||||
|
{loading
|
||||||
|
? "Loading…"
|
||||||
|
: `${filtered.length} shown · ${totalCount} total from API`}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<SpinnerIcon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||||
|
<p className="text-sm text-destructive">Could not load email templates.</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
|
<CardContent className="flex flex-col items-center gap-4 px-6 py-16 text-center">
|
||||||
|
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-brand-100/50 text-brand-500">
|
||||||
|
<Mail className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-grayScale-500">
|
||||||
|
{templates.length === 0
|
||||||
|
? "No email templates returned from the API."
|
||||||
|
: "No templates match your search or filters."}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filtered.map((template) => (
|
||||||
|
<Card
|
||||||
|
key={template.id}
|
||||||
|
className="flex flex-col overflow-hidden border border-grayScale-200 rounded-xl bg-white shadow-none transition-shadow hover:shadow-soft"
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-1 flex-col gap-4 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-brand-100/40 text-brand-500">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
|
<Badge variant={emailTemplateStatusBadgeVariant(template.status)}>
|
||||||
|
{template.status}
|
||||||
|
</Badge>
|
||||||
|
{template.is_system ? (
|
||||||
|
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
System
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h3 className="text-lg font-bold leading-snug text-grayScale-900">
|
||||||
|
{template.name}
|
||||||
|
</h3>
|
||||||
|
<code className="inline-block rounded bg-grayScale-100 px-1.5 py-0.5 text-xs text-grayScale-600">
|
||||||
|
{template.slug}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-grayScale-400">
|
||||||
|
Subject
|
||||||
|
</p>
|
||||||
|
<p className="line-clamp-2 text-sm text-grayScale-600">{template.subject}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-grayScale-400">
|
||||||
|
Variables
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(template.variables ?? []).length > 0 ? (
|
||||||
|
(template.variables ?? []).map((v) => (
|
||||||
|
<Badge key={v} variant="secondary" className="text-[10px]">
|
||||||
|
{`{{.${v}}}`}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-grayScale-400">None</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-2 border-t border-grayScale-100 pt-4">
|
||||||
|
<span className="text-xs text-grayScale-400">
|
||||||
|
Updated{" "}
|
||||||
|
{formatEmailTemplateDate(
|
||||||
|
template.updated_at || template.created_at,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to={`/notifications/email-templates/${template.slug}`}>
|
||||||
|
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{!template.is_system ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => setTemplatePendingDelete(template)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">Delete</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmailTemplateDeleteDialog
|
||||||
|
template={templatePendingDelete}
|
||||||
|
open={templatePendingDelete !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setTemplatePendingDelete(null)
|
||||||
|
}}
|
||||||
|
onDeleted={() => {
|
||||||
|
setTemplatePendingDelete(null)
|
||||||
|
void load()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -549,7 +549,7 @@ export function NotificationsPage() {
|
||||||
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
|
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">My Notifications</h1>
|
||||||
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>}
|
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>}
|
||||||
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
|
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
195
src/pages/notifications/components/EmailTemplateCreateForm.tsx
Normal file
195
src/pages/notifications/components/EmailTemplateCreateForm.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { Badge } from "../../../components/ui/badge"
|
||||||
|
import { Button } from "../../../components/ui/button"
|
||||||
|
import { Input } from "../../../components/ui/input"
|
||||||
|
import { Textarea } from "../../../components/ui/textarea"
|
||||||
|
import type { EmailTemplateStatus } from "../../../types/emailTemplate.types"
|
||||||
|
|
||||||
|
export type EmailTemplateCreateDraft = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
subject: string
|
||||||
|
body_text: string
|
||||||
|
body_html: string
|
||||||
|
variablesText: string
|
||||||
|
status: EmailTemplateStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_EMAIL_TEMPLATE_CREATE_DRAFT: EmailTemplateCreateDraft = {
|
||||||
|
slug: "",
|
||||||
|
name: "",
|
||||||
|
subject: "",
|
||||||
|
body_text: "",
|
||||||
|
body_html: "",
|
||||||
|
variablesText: "",
|
||||||
|
status: "ACTIVE",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse variable names from comma- or newline-separated input. */
|
||||||
|
export function parseEmailTemplateVariables(text: string): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: string[] = []
|
||||||
|
for (const part of text.split(/[\n,]+/)) {
|
||||||
|
const name = part.trim()
|
||||||
|
if (!name || seen.has(name)) continue
|
||||||
|
seen.add(name)
|
||||||
|
result.push(name)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugFromTemplateName(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailTemplateCreateFormProps = {
|
||||||
|
draft: EmailTemplateCreateDraft
|
||||||
|
saving: boolean
|
||||||
|
onChange: (patch: Partial<EmailTemplateCreateDraft>) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailTemplateCreateForm({
|
||||||
|
draft,
|
||||||
|
saving,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
}: EmailTemplateCreateFormProps) {
|
||||||
|
const variables = parseEmailTemplateVariables(draft.variablesText)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Name
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => onChange({ name: e.target.value })}
|
||||||
|
placeholder="Course Reminder"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Slug
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={draft.slug}
|
||||||
|
onChange={(e) => onChange({ slug: e.target.value })}
|
||||||
|
placeholder="course_reminder"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-grayScale-400">
|
||||||
|
Lowercase letters, numbers, and underscores only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={draft.status}
|
||||||
|
onChange={(e) => onChange({ status: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex h-10 w-full max-w-xs rounded-md border border-grayScale-200 bg-white px-3 py-2 text-sm text-grayScale-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">ACTIVE</option>
|
||||||
|
<option value="INACTIVE">INACTIVE</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Subject
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={draft.subject}
|
||||||
|
onChange={(e) => onChange({ subject: e.target.value })}
|
||||||
|
placeholder="Reminder: {{.CourseName}}"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Plain text body
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={draft.body_text}
|
||||||
|
onChange={(e) => onChange({ body_text: e.target.value })}
|
||||||
|
rows={8}
|
||||||
|
className="min-h-[160px] resize-y font-mono text-sm"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
HTML body
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={draft.body_html}
|
||||||
|
onChange={(e) => onChange({ body_html: e.target.value })}
|
||||||
|
rows={14}
|
||||||
|
className="min-h-[280px] resize-y font-mono text-xs leading-relaxed"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Variables
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={draft.variablesText}
|
||||||
|
onChange={(e) => onChange({ variablesText: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
placeholder="FirstName, CourseName, Link"
|
||||||
|
className="resize-y font-mono text-sm"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-grayScale-400">
|
||||||
|
One per line or comma-separated. Refer in templates as{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1">{`{{.VarName}}`}</code>.
|
||||||
|
</p>
|
||||||
|
{variables.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{variables.map((v) => (
|
||||||
|
<Badge key={v} variant="secondary">
|
||||||
|
{`{{.${v}}}`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-grayScale-100 pt-4">
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{saving ? "Creating…" : "Create template"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" disabled={saving} onClick={onReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
Saved with{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1">POST /admin/email-templates</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { deleteEmailTemplate } from "../../../api/emailTemplates.api"
|
||||||
|
import { Button } from "../../../components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../../components/ui/dialog"
|
||||||
|
import type { EmailTemplate } from "../../../types/emailTemplate.types"
|
||||||
|
|
||||||
|
type EmailTemplateDeleteDialogProps = {
|
||||||
|
template: EmailTemplate | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onDeleted?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailTemplateDeleteDialog({
|
||||||
|
template,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onDeleted,
|
||||||
|
}: EmailTemplateDeleteDialogProps) {
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
if (!next && !deleting) onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!template) return
|
||||||
|
if (template.is_system) {
|
||||||
|
toast.error("System templates cannot be deleted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
const response = await deleteEmailTemplate(template.id)
|
||||||
|
toast.success(response.data?.message ?? "Email template deleted")
|
||||||
|
onOpenChange(false)
|
||||||
|
onDeleted?.()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(e)
|
||||||
|
const msg =
|
||||||
|
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to delete email template"
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
|
||||||
|
<Trash2 className="h-5 w-5 shrink-0 text-destructive" aria-hidden />
|
||||||
|
Delete email template?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-left text-grayScale-600">
|
||||||
|
This permanently removes the template. This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{template ? (
|
||||||
|
<div className="space-y-1 rounded-xl border border-grayScale-200 bg-grayScale-50 px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-grayScale-900">{template.name}</p>
|
||||||
|
<p className="break-all font-mono text-xs text-grayScale-500">
|
||||||
|
#{template.id} · {template.slug}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleting || !template}
|
||||||
|
onClick={() => void handleConfirm()}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete template"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/pages/notifications/components/EmailTemplateEditForm.tsx
Normal file
130
src/pages/notifications/components/EmailTemplateEditForm.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Badge } from "../../../components/ui/badge"
|
||||||
|
import { Button } from "../../../components/ui/button"
|
||||||
|
import { Input } from "../../../components/ui/input"
|
||||||
|
import { Textarea } from "../../../components/ui/textarea"
|
||||||
|
import type { EmailTemplate } from "../../../types/emailTemplate.types"
|
||||||
|
|
||||||
|
export type EmailTemplateDraft = {
|
||||||
|
subject: string
|
||||||
|
body_text: string
|
||||||
|
body_html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailTemplateDraftFromTemplate(
|
||||||
|
template: EmailTemplate,
|
||||||
|
): EmailTemplateDraft {
|
||||||
|
return {
|
||||||
|
subject: template.subject,
|
||||||
|
body_text: template.body_text,
|
||||||
|
body_html: template.body_html,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftsEqual(a: EmailTemplateDraft, b: EmailTemplateDraft) {
|
||||||
|
return (
|
||||||
|
a.subject === b.subject &&
|
||||||
|
a.body_text === b.body_text &&
|
||||||
|
a.body_html === b.body_html
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailTemplateEditFormProps = {
|
||||||
|
template: EmailTemplate
|
||||||
|
draft: EmailTemplateDraft
|
||||||
|
saving: boolean
|
||||||
|
onChange: (patch: Partial<EmailTemplateDraft>) => void
|
||||||
|
onSave: () => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailTemplateEditForm({
|
||||||
|
template,
|
||||||
|
draft,
|
||||||
|
saving,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onReset,
|
||||||
|
}: EmailTemplateEditFormProps) {
|
||||||
|
const isDirty = !draftsEqual(draft, emailTemplateDraftFromTemplate(template))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Subject
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={draft.subject}
|
||||||
|
onChange={(e) => onChange({ subject: e.target.value })}
|
||||||
|
placeholder="Email subject line"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Plain text body
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={draft.body_text}
|
||||||
|
onChange={(e) => onChange({ body_text: e.target.value })}
|
||||||
|
rows={8}
|
||||||
|
className="min-h-[160px] resize-y font-mono text-sm"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
HTML body
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
value={draft.body_html}
|
||||||
|
onChange={(e) => onChange({ body_html: e.target.value })}
|
||||||
|
rows={14}
|
||||||
|
className="min-h-[280px] resize-y font-mono text-xs leading-relaxed"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Allowed variables (read-only)
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(template.variables ?? []).map((v) => (
|
||||||
|
<Badge key={v} variant="secondary">
|
||||||
|
{`{{.${v}}}`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-grayScale-400">
|
||||||
|
Use Go template syntax, e.g.{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1">{`{{if .FirstName}}`}</code>.
|
||||||
|
Saved with{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1">
|
||||||
|
PUT /admin/email-templates/{template.id}
|
||||||
|
</code>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-grayScale-100 pt-4">
|
||||||
|
<Button
|
||||||
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
disabled={saving || !isDirty}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" disabled={saving || !isDirty} onClick={onReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
{!isDirty ? (
|
||||||
|
<span className="text-xs text-grayScale-400">No unsaved changes</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
src/pages/notifications/components/EmailTemplatePreviewPanel.tsx
Normal file
119
src/pages/notifications/components/EmailTemplatePreviewPanel.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Badge } from "../../../components/ui/badge"
|
||||||
|
import { cn } from "../../../lib/utils"
|
||||||
|
import {
|
||||||
|
renderEmailTemplatePreview,
|
||||||
|
} from "../../../lib/emailTemplatePreview"
|
||||||
|
import type { EmailTemplatePreviewSource } from "../../../types/emailTemplate.types"
|
||||||
|
|
||||||
|
type PreviewMode = "rendered-html" | "rendered-text" | "source-html" | "source-text"
|
||||||
|
|
||||||
|
export function EmailTemplatePreviewPanel({
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
source: EmailTemplatePreviewSource
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<PreviewMode>("rendered-html")
|
||||||
|
|
||||||
|
const variables = source.variables ?? []
|
||||||
|
|
||||||
|
const renderedText = useMemo(
|
||||||
|
() => renderEmailTemplatePreview(source.body_text, variables),
|
||||||
|
[source.body_text, variables],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderedHtml = useMemo(
|
||||||
|
() => renderEmailTemplatePreview(source.body_html, variables),
|
||||||
|
[source.body_html, variables],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderedSubject = useMemo(
|
||||||
|
() => renderEmailTemplatePreview(source.subject, variables),
|
||||||
|
[source.subject, variables],
|
||||||
|
)
|
||||||
|
|
||||||
|
const tabs: { id: PreviewMode; label: string }[] = [
|
||||||
|
{ id: "rendered-html", label: "HTML preview" },
|
||||||
|
{ id: "rendered-text", label: "Plain text preview" },
|
||||||
|
{ id: "source-html", label: "HTML source" },
|
||||||
|
{ id: "source-text", label: "Text source" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Subject
|
||||||
|
</p>
|
||||||
|
<p className="rounded-lg border border-grayScale-100 bg-grayScale-50/80 px-3 py-2 text-sm text-grayScale-800">
|
||||||
|
{renderedSubject}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-grayScale-400">
|
||||||
|
Source: <code className="text-[10px]">{source.subject}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Template variables
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{variables.map((v) => (
|
||||||
|
<Badge key={v} variant="secondary">
|
||||||
|
{`{{.${v}}}`}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-grayScale-400">
|
||||||
|
HTML and text previews use sample placeholder values for variables (not live
|
||||||
|
sends).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-b border-grayScale-100 pb-2">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
|
||||||
|
mode === tab.id
|
||||||
|
? "bg-brand-100 text-brand-600"
|
||||||
|
: "text-grayScale-500 hover:bg-grayScale-100",
|
||||||
|
)}
|
||||||
|
onClick={() => setMode(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "rendered-html" ? (
|
||||||
|
<iframe
|
||||||
|
title={`HTML preview ${source.slug ?? "template"}`}
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={renderedHtml}
|
||||||
|
className="h-[min(480px,60vh)] w-full rounded-lg border border-grayScale-200 bg-white"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mode === "rendered-text" ? (
|
||||||
|
<pre className="max-h-[min(480px,60vh)] overflow-auto whitespace-pre-wrap rounded-lg border border-grayScale-100 bg-grayScale-50/80 p-4 text-sm leading-relaxed text-grayScale-700">
|
||||||
|
{renderedText}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mode === "source-html" ? (
|
||||||
|
<pre className="max-h-[min(480px,60vh)] overflow-auto whitespace-pre-wrap rounded-lg border border-grayScale-100 bg-grayScale-50/80 p-3 text-xs leading-relaxed text-grayScale-600">
|
||||||
|
{source.body_html}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mode === "source-text" ? (
|
||||||
|
<pre className="max-h-[min(480px,60vh)] overflow-auto whitespace-pre-wrap rounded-lg border border-grayScale-100 bg-grayScale-50/80 p-3 text-sm leading-relaxed text-grayScale-700">
|
||||||
|
{source.body_text}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
UserX,
|
UserX,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
Mail,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
|
|
@ -38,6 +39,8 @@ import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
import { teamRoleFromRbacRole } from "../../lib/teamRoles"
|
||||||
|
import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog"
|
||||||
|
|
||||||
export function RolesListPage() {
|
export function RolesListPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -83,6 +86,8 @@ export function RolesListPage() {
|
||||||
const [permSearch, setPermSearch] = useState("")
|
const [permSearch, setPermSearch] = useState("")
|
||||||
const [savingPermissions, setSavingPermissions] = useState(false)
|
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||||
|
|
||||||
|
const [inviteForRole, setInviteForRole] = useState<Role | null>(null)
|
||||||
|
|
||||||
// Debounce search query
|
// Debounce search query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -465,6 +470,18 @@ export function RolesListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-full gap-1.5 border-brand-200 text-xs text-brand-600 hover:bg-brand-50"
|
||||||
|
onClick={() => setInviteForRole(role)}
|
||||||
|
disabled={deleteLoading || bulkActionLoading}
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Invite team members
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -966,6 +983,17 @@ export function RolesListPage() {
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<InviteTeamMemberDialog
|
||||||
|
open={inviteForRole !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setInviteForRole(null)
|
||||||
|
}}
|
||||||
|
presetTeamRole={
|
||||||
|
inviteForRole ? teamRoleFromRbacRole(inviteForRole) : undefined
|
||||||
|
}
|
||||||
|
presetRoleLabel={inviteForRole?.name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
293
src/pages/role-management/components/InviteTeamMemberDialog.tsx
Normal file
293
src/pages/role-management/components/InviteTeamMemberDialog.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Mail, Shield } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { inviteTeamMember } from "../../../api/team.api"
|
||||||
|
import { Button } from "../../../components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../../components/ui/dialog"
|
||||||
|
import { Input } from "../../../components/ui/input"
|
||||||
|
import { Select } from "../../../components/ui/select"
|
||||||
|
import { Textarea } from "../../../components/ui/textarea"
|
||||||
|
import { cn } from "../../../lib/utils"
|
||||||
|
import {
|
||||||
|
isValidInviteEmail,
|
||||||
|
parseInviteEmails,
|
||||||
|
type InviteEmailSendResult,
|
||||||
|
} from "../../../lib/parseInviteEmails"
|
||||||
|
import { formatTeamRoleLabel, TEAM_ROLE_OPTIONS } from "../../../lib/teamRoles"
|
||||||
|
|
||||||
|
type InviteTeamMemberDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
presetTeamRole?: string
|
||||||
|
presetRoleLabel?: string
|
||||||
|
onInvited?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteTeamMemberDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
presetTeamRole,
|
||||||
|
presetRoleLabel,
|
||||||
|
onInvited,
|
||||||
|
}: InviteTeamMemberDialogProps) {
|
||||||
|
const roleLocked = Boolean(presetTeamRole?.trim())
|
||||||
|
const lockedRole = presetTeamRole?.trim() ?? ""
|
||||||
|
|
||||||
|
const [emailsText, setEmailsText] = useState("")
|
||||||
|
const [teamRole, setTeamRole] = useState(lockedRole || "CONTENT_MANAGER")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [progress, setProgress] = useState<{ current: number; total: number } | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [results, setResults] = useState<InviteEmailSendResult[] | null>(null)
|
||||||
|
|
||||||
|
const parsedEmails = useMemo(() => parseInviteEmails(emailsText), [emailsText])
|
||||||
|
const invalidEmails = useMemo(
|
||||||
|
() => parsedEmails.filter((e) => !isValidInviteEmail(e)),
|
||||||
|
[parsedEmails],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setEmailsText("")
|
||||||
|
setTeamRole(lockedRole || "CONTENT_MANAGER")
|
||||||
|
setResults(null)
|
||||||
|
setProgress(null)
|
||||||
|
}, [open, lockedRole])
|
||||||
|
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
if (!next && !submitting) onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendInvitations = async (emails: string[], role: string) => {
|
||||||
|
const outcome: InviteEmailSendResult[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < emails.length; i++) {
|
||||||
|
const email = emails[i]
|
||||||
|
setProgress({ current: i + 1, total: emails.length })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await inviteTeamMember({ email, team_role: role })
|
||||||
|
outcome.push({
|
||||||
|
email,
|
||||||
|
success: true,
|
||||||
|
message: res.data?.message ?? "Invitation sent",
|
||||||
|
invitationId: res.data?.data?.invitation_id,
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? "Failed to send invitation"
|
||||||
|
outcome.push({ email, success: false, message: msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const role = roleLocked ? lockedRole : teamRole
|
||||||
|
|
||||||
|
if (parsedEmails.length === 0) {
|
||||||
|
toast.error("Enter at least one email address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (invalidEmails.length > 0) {
|
||||||
|
toast.error(`Invalid email: ${invalidEmails.join(", ")}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!role) {
|
||||||
|
toast.error("Team role is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setResults(null)
|
||||||
|
try {
|
||||||
|
const outcome = await sendInvitations(parsedEmails, role)
|
||||||
|
setResults(outcome)
|
||||||
|
|
||||||
|
const succeeded = outcome.filter((r) => r.success)
|
||||||
|
const failed = outcome.filter((r) => !r.success)
|
||||||
|
|
||||||
|
if (failed.length === 0) {
|
||||||
|
toast.success(
|
||||||
|
outcome.length === 1
|
||||||
|
? "Team invitation sent successfully"
|
||||||
|
: `${succeeded.length} invitations sent successfully`,
|
||||||
|
)
|
||||||
|
onOpenChange(false)
|
||||||
|
onInvited?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (succeeded.length === 0) {
|
||||||
|
toast.error("No invitations were sent")
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
`${succeeded.length} sent, ${failed.length} failed. Review details below.`,
|
||||||
|
)
|
||||||
|
onInvited?.()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
setProgress(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDisplay = presetRoleLabel?.trim() || formatTeamRoleLabel(teamRole)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-md overflow-y-auto rounded-2xl border-grayScale-200 sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
||||||
|
Invite team members
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-left text-grayScale-600">
|
||||||
|
Sends one{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||||
|
POST /team/members/invite
|
||||||
|
</code>{" "}
|
||||||
|
request per email. Invitees complete setup at{" "}
|
||||||
|
<code className="rounded bg-grayScale-100 px-1 text-xs">/accept-invite</code>
|
||||||
|
{roleLocked ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
with role{" "}
|
||||||
|
<span className="font-semibold text-grayScale-800">{roleDisplay}</span>.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"."
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="invite-emails"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
Email addresses
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
id="invite-emails"
|
||||||
|
value={emailsText}
|
||||||
|
onChange={(e) => setEmailsText(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
"one@example.com\nother@example.com\n\nOr comma-separated"
|
||||||
|
}
|
||||||
|
rows={5}
|
||||||
|
className="min-h-[120px] resize-y font-mono text-sm"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs text-grayScale-500">
|
||||||
|
{parsedEmails.length === 0
|
||||||
|
? "One email per line, or separated by commas"
|
||||||
|
: `${parsedEmails.length} email${parsedEmails.length === 1 ? "" : "s"} ready to invite`}
|
||||||
|
{invalidEmails.length > 0 ? (
|
||||||
|
<span className="text-destructive">
|
||||||
|
{" "}
|
||||||
|
· {invalidEmails.length} invalid
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="invite-role"
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
|
||||||
|
>
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
Team role
|
||||||
|
</label>
|
||||||
|
{roleLocked ? (
|
||||||
|
<Input
|
||||||
|
id="invite-role"
|
||||||
|
readOnly
|
||||||
|
value={roleDisplay}
|
||||||
|
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
id="invite-role"
|
||||||
|
value={teamRole}
|
||||||
|
onChange={(e) => setTeamRole(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{TEAM_ROLE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progress ? (
|
||||||
|
<p className="text-center text-xs font-medium text-brand-600">
|
||||||
|
Sending invitation {progress.current} of {progress.total}…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{results && results.length > 0 ? (
|
||||||
|
<div className="max-h-40 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-200 bg-grayScale-50/80 p-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-grayScale-500">
|
||||||
|
Results
|
||||||
|
</p>
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.email}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2 py-1.5 text-xs",
|
||||||
|
r.success
|
||||||
|
? "bg-mint-500/10 text-grayScale-700"
|
||||||
|
: "bg-destructive/10 text-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{r.email}</span>
|
||||||
|
<span className="text-grayScale-500"> — {r.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 pt-2 sm:gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{results ? "Close" : "Cancel"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-brand-500 text-white hover:bg-brand-600"
|
||||||
|
disabled={submitting || parsedEmails.length === 0}
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? progress
|
||||||
|
? `Sending ${progress.current}/${progress.total}…`
|
||||||
|
: "Sending…"
|
||||||
|
: parsedEmails.length <= 1
|
||||||
|
? "Send invitation"
|
||||||
|
: `Send ${parsedEmails.length} invitations`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,12 @@ export interface DashboardUsers {
|
||||||
by_role: LabelCount[]
|
by_role: LabelCount[]
|
||||||
by_status: LabelCount[]
|
by_status: LabelCount[]
|
||||||
by_age_group: LabelCount[]
|
by_age_group: LabelCount[]
|
||||||
by_knowledge_level: LabelCount[]
|
by_education_level: LabelCount[]
|
||||||
|
by_occupation: LabelCount[]
|
||||||
|
by_learning_goal: LabelCount[]
|
||||||
|
/** API field name (typo preserved to match backend). */
|
||||||
|
by_language_challange: LabelCount[]
|
||||||
|
by_knowledge_level?: LabelCount[]
|
||||||
by_region: LabelCount[]
|
by_region: LabelCount[]
|
||||||
registrations_last_30_days: DateCount[]
|
registrations_last_30_days: DateCount[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/types/emailTemplate.types.ts
Normal file
81
src/types/emailTemplate.types.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
export type EmailTemplateStatus = "ACTIVE" | "INACTIVE" | string
|
||||||
|
|
||||||
|
export interface EmailTemplate {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
subject: string
|
||||||
|
body_text: string
|
||||||
|
body_html: string
|
||||||
|
variables: string[]
|
||||||
|
is_system: boolean
|
||||||
|
status: EmailTemplateStatus
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetEmailTemplatesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
templates: EmailTemplate[]
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetEmailTemplateBySlugResponse {
|
||||||
|
message: string
|
||||||
|
data: EmailTemplate
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for PUT /admin/email-templates/:id */
|
||||||
|
export interface UpdateEmailTemplateRequest {
|
||||||
|
subject: string
|
||||||
|
body_text: string
|
||||||
|
body_html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmailTemplateResponse {
|
||||||
|
message: string
|
||||||
|
data: EmailTemplate
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for POST /admin/email-templates */
|
||||||
|
export interface CreateEmailTemplateRequest {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
subject: string
|
||||||
|
body_text: string
|
||||||
|
body_html: string
|
||||||
|
variables: string[]
|
||||||
|
status: EmailTemplateStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmailTemplateResponse {
|
||||||
|
message: string
|
||||||
|
data: EmailTemplate
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteEmailTemplateResponse {
|
||||||
|
message: string
|
||||||
|
data?: unknown
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailTemplatePreviewSource = Pick<
|
||||||
|
EmailTemplate,
|
||||||
|
"subject" | "body_text" | "body_html" | "variables"
|
||||||
|
> & { slug?: string }
|
||||||
60
src/types/teamInvitation.types.ts
Normal file
60
src/types/teamInvitation.types.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
export type TeamInvitationStatus = "pending" | "accepted" | "expired" | "revoked" | string
|
||||||
|
|
||||||
|
/** GET /team/invitations/verify?token= — data payload */
|
||||||
|
export interface VerifyInvitationData {
|
||||||
|
valid: boolean
|
||||||
|
email?: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
team_role?: string
|
||||||
|
needs_profile_setup?: boolean
|
||||||
|
expires_at?: string
|
||||||
|
status?: TeamInvitationStatus
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyInvitationResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
data: VerifyInvitationData
|
||||||
|
status_code?: number
|
||||||
|
metadata?: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /team/invitations/accept — finalize account setup */
|
||||||
|
export interface AcceptInvitationRequest {
|
||||||
|
token: string
|
||||||
|
password: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
phone_number: string
|
||||||
|
department: string
|
||||||
|
job_title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptInvitationResponse {
|
||||||
|
message: string
|
||||||
|
data?: unknown
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /team/members/invite */
|
||||||
|
export interface InviteTeamMemberRequest {
|
||||||
|
email: string
|
||||||
|
team_role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteTeamMemberResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
invitation_id: number
|
||||||
|
team_member_id: number
|
||||||
|
email: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user