diff --git a/package-lock.json b/package-lock.json index 3a96594..bc82fa9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "react-is": "^19.2.5", "react-router-dom": "^7.10.1", "recharts": "^3.6.0", + "resend": "^6.12.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" @@ -92,6 +93,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -360,6 +362,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2670,6 +2673,12 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2810,6 +2819,7 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2820,6 +2830,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2830,6 +2841,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2885,6 +2897,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -3136,6 +3149,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3374,6 +3388,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3950,6 +3965,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4185,6 +4201,12 @@ "dev": true, "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": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -5064,6 +5086,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5091,6 +5114,12 @@ "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": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -5111,6 +5140,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5299,6 +5329,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5308,6 +5339,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5319,13 +5351,15 @@ "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5531,7 +5565,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -5548,6 +5583,27 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "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": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5721,6 +5777,16 @@ "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": { "version": "3.1.1", "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" } }, + "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": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -5935,6 +6010,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6102,6 +6178,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6239,6 +6316,7 @@ "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 57f998e..8cfbe32 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react-is": "^19.2.5", "react-router-dom": "^7.10.1", "recharts": "^3.6.0", + "resend": "^6.12.3", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zustand": "^5.0.9" diff --git a/src/api/analytics.api.ts b/src/api/analytics.api.ts index 67ec8b2..82cddd8 100644 --- a/src/api/analytics.api.ts +++ b/src/api/analytics.api.ts @@ -1,5 +1,11 @@ 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 { if (!filters || filters.mode === "all_time") { @@ -21,19 +27,150 @@ function buildDashboardQueryParams(filters?: DashboardFilters): Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function pickField(record: Record, ...keys: string[]): unknown { + for (const key of keys) { + if (key in record && record[key] != null) return record[key]; } - return body as DashboardData; + 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 { + 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 { + 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): 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) => http - .get("/analytics/dashboard", { + .get("/analytics/dashboard", { params: buildDashboardQueryParams(filters), }) .then((res) => ({ ...res, - data: unwrapDashboardResponse(res.data), + data: normalizeDashboardResponse(res.data), })); diff --git a/src/api/emailTemplates.api.ts b/src/api/emailTemplates.api.ts new file mode 100644 index 0000000..3fc8a2a --- /dev/null +++ b/src/api/emailTemplates.api.ts @@ -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("/admin/email-templates") + +/** GET /admin/email-templates/slug/:slug — single template by slug. */ +export const getEmailTemplateBySlug = (slug: string) => + http.get( + `/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>, +): 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(`/admin/email-templates/${id}`, data) + +/** POST /admin/email-templates — create a custom template. */ +export const createEmailTemplate = (data: CreateEmailTemplateRequest) => + http.post("/admin/email-templates", data) + +/** DELETE /admin/email-templates/:id — delete a custom template. */ +export const deleteEmailTemplate = (id: number) => + http.delete(`/admin/email-templates/${id}`) + +export function parseEmailTemplateResponse( + response: + | Awaited> + | Awaited> + | Awaited>, +): EmailTemplate | null { + return normalizeEmailTemplate(response.data?.data) +} diff --git a/src/api/http.ts b/src/api/http.ts index 61a757b..ff0dcf0 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -59,7 +59,9 @@ const isAuthEndpointRequest = (url?: string) => { return ( url.includes("/team/login") || url.includes("/team/google-login") || - url.includes("/team/refresh") + url.includes("/team/refresh") || + url.includes("/team/invitations/verify") || + url.includes("/team/invitations/accept") ); }; diff --git a/src/api/team.api.ts b/src/api/team.api.ts index bfd5d4b..c3f8814 100644 --- a/src/api/team.api.ts +++ b/src/api/team.api.ts @@ -1,4 +1,11 @@ import http from "./http" +import type { + AcceptInvitationRequest, + AcceptInvitationResponse, + InviteTeamMemberRequest, + InviteTeamMemberResponse, + VerifyInvitationResponse, +} from "../types/teamInvitation.types" import type { GetTeamMembersResponse, GetTeamMemberResponse, @@ -25,3 +32,27 @@ export const updateTeamMemberStatus = (id: number, status: string) => export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) => http.put(`/team/members/${id}`, data) + +/** POST /team/members/invite — send invitation email (permission: team.members.invite). */ +export const inviteTeamMember = (data: InviteTeamMemberRequest) => + http.post("/team/members/invite", data) + +/** GET /team/invitations/verify?token= — public (accept-invite page). */ +export const verifyTeamInvitation = (token: string) => + http.get("/team/invitations/verify", { + params: { token }, + }) + +/** POST /team/invitations/accept — public (set password after invite). */ +export const acceptTeamInvitation = (data: AcceptInvitationRequest) => + http.post("/team/invitations/accept", data) + +export function parseVerifyInvitation( + response: Awaited>, +): VerifyInvitationResponse["data"] | null { + const body = response.data + if (body?.data && typeof body.data === "object" && "valid" in body.data) { + return body.data + } + return null +} diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 73edf24..1d01c37 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -35,6 +35,9 @@ import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuesti import { NotFoundPage } from "../pages/NotFoundPage"; import { NotificationsPage } from "../pages/notifications/NotificationsPage"; 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 { UserManagementLayout } from "../pages/user-management/UserManagementLayout"; import { UsersListPage } from "../pages/user-management/UsersListPage"; @@ -59,6 +62,7 @@ import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage"; import { LoginPage } from "../pages/auth/LoginPage"; import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"; import { VerificationPage } from "../pages/auth/VerificationPage"; +import { AcceptInvitePage } from "../pages/auth/AcceptInvitePage"; import { AboutPage } from "../pages/AboutPage"; import { TermsPage } from "../pages/TermsPage"; import { PrivacyPage } from "../pages/PrivacyPage"; @@ -70,6 +74,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> @@ -234,6 +239,18 @@ export function AppRoutes() { /> } /> + } + /> + } + /> + } + /> } diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index fdabf56..4214b37 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -20,27 +20,47 @@ import { NavLink } from "react-router-dom"; import { cn } from "../../lib/utils"; import { BrandLogo } from "../brand/BrandLogo"; import { getUnreadCount } from "../../api/notifications.api"; +import { SidebarNavGroup } from "./SidebarNavGroup"; -type NavItem = { +type NavLinkItem = { + kind: "link"; label: string; to: string; icon: ComponentType<{ className?: string }>; }; -const navItems: NavItem[] = [ - { label: "Dashboard", to: "/dashboard", icon: LayoutDashboard }, - { label: "User Management", to: "/users", icon: Users }, - { label: "Role Management", to: "/roles", icon: Shield }, - { label: "Content Management", to: "/content", icon: BookOpen }, - { label: "New Content", to: "/new-content", icon: BookOpen }, +type NavGroupItem = { + kind: "group"; + label: string; + basePath: string; + icon: ComponentType<{ className?: string }>; + children: { label: string; to: string; end?: boolean }[]; +}; - { label: "Notifications", to: "/notifications", icon: Bell }, - { label: "User Log", to: "/user-log", icon: ClipboardList }, - { label: "Issue Reports", to: "/issues", icon: CircleAlert }, - { label: "Analytics", to: "/analytics", icon: BarChart3 }, - { label: "Team Management", to: "/team", icon: Users2 }, - { label: "Profile", to: "/profile", icon: UserCircle2 }, - { label: "Settings", to: "/settings", icon: Settings }, +type NavEntry = NavLinkItem | NavGroupItem; + +const navEntries: NavEntry[] = [ + { kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard }, + { kind: "link", label: "User Management", to: "/users", icon: Users }, + { kind: "link", label: "Role Management", to: "/roles", icon: Shield }, + { 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 = { @@ -75,9 +95,18 @@ export function Sidebar({ window.removeEventListener("notifications-updated", fetchUnread); }, []); + const unreadBadge = unreadCount > 0 && ( + + {unreadCount > 99 ? "99+" : unreadCount} + + ); + + const collapsedUnreadDot = unreadCount > 0 && ( + + ); + return ( <> - {/* Mobile overlay */}