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:
Yared Yemane 2026-05-22 10:21:55 -07:00
parent b8a73c73db
commit e75420e756
30 changed files with 3050 additions and 81 deletions

82
package-lock.json generated
View File

@ -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"
}

View File

@ -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"

View File

@ -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<string, string | number> {
if (!filters || filters.mode === "all_time") {
@ -21,19 +27,150 @@ function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, s
return {};
}
function unwrapDashboardResponse(body: DashboardResponse | DashboardData): DashboardData {
if (body && typeof body === "object" && "data" in body && body.data) {
return body.data;
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
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 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<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) =>
http
.get<DashboardResponse | DashboardData>("/analytics/dashboard", {
.get<unknown>("/analytics/dashboard", {
params: buildDashboardQueryParams(filters),
})
.then((res) => ({
...res,
data: unwrapDashboardResponse(res.data),
data: normalizeDashboardResponse(res.data),
}));

View 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)
}

View File

@ -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")
);
};

View File

@ -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<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
}

View File

@ -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() {
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/verification" element={<VerificationPage />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
@ -234,6 +239,18 @@ export function AppRoutes() {
/>
<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
path="/notifications/create"
element={<CreateNotificationPage />}

View File

@ -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 && (
<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 (
<>
{/* Mobile overlay */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
@ -87,7 +116,6 @@ export function Sidebar({
aria-hidden="true"
/>
{/* Sidebar panel */}
<aside
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",
@ -135,12 +163,27 @@ export function Sidebar({
</div>
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const Icon = item.icon;
{navEntries.map((entry) => {
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 (
<NavLink
key={item.to}
to={item.to}
key={entry.to}
to={entry.to}
onClick={onClose}
className={({ isActive }) =>
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",
)
}
title={isCollapsed ? item.label : undefined}
title={isCollapsed ? entry.label : undefined}
>
{({ 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",
"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" />
{isCollapsed &&
item.to === "/notifications" &&
unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
</span>
{!isCollapsed && (
<span className="truncate">{item.label}</span>
<span className="truncate">{entry.label}</span>
)}
{!isCollapsed &&
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 ? (
{!isCollapsed && isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : null}
</>

View 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>
);
}

View File

@ -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 = [
"Jan",
@ -84,3 +130,11 @@ export function getSeriesPeriodLabel(dateFilter?: DashboardDateFilter): string {
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
}

View 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
}

View 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
View 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
View 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())
}

View File

@ -10,6 +10,7 @@ import {
TicketCheck,
// TrendingUp,
Users,
UserX,
Bell,
CreditCard,
UsersRound,
@ -39,7 +40,12 @@ import { getSubscriptionPlans } from "../api/subscription-plans.api"
import { getRatings } from "../api/courses.api"
import { useEffect, useState } from "react"
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 { SubscriptionPlan } from "../types/subscription.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 subscriptionMetrics = dashboard
? getSubscriptionMetrics(dashboard.subscriptions)
: null
return (
<div className="mx-auto w-full max-w-6xl">
@ -234,11 +243,11 @@ export function DashboardPage() {
deltaPositive={dashboard.users.new_month > 0}
/>
<StatCard
icon={BadgeCheck}
label="Active Subscribers"
value={dashboard.subscriptions.active_subscriptions.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive={dashboard.subscriptions.new_month > 0}
icon={CreditCard}
label="Payments"
value={dashboard.payments.total_payments.toLocaleString()}
deltaLabel={`${dashboard.payments.successful_payments} successful`}
deltaPositive={dashboard.payments.successful_payments > 0}
/>
<StatCard
icon={DollarSign}
@ -258,8 +267,33 @@ export function DashboardPage() {
)}
{/* Secondary Stats */}
{activeStatTab === "secondary" && (
{activeStatTab === "secondary" && subscriptionMetrics && (
<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
icon={Video}
label="Videos"

View File

@ -43,6 +43,8 @@ import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../compone
import {
getPrimaryQuestionTypeSummary,
getSeriesPeriodLabel,
formatAnalyticsLabel,
getSubscriptionMetrics,
getVideoLessonsSummary,
} from "../../lib/analytics"
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
@ -115,31 +117,43 @@ function BreakdownList({
title,
data,
total,
scrollable,
}: {
title: string
data: LabelCount[]
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 sorted = [...data].sort((a, b) => b.count - a.count)
return (
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{data.length > 0 ? (
<div className="space-y-2.5">
{data.map((item, i) => {
{sorted.length > 0 ? (
<div
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 displayLabel = formatAnalyticsLabel(item.label)
return (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<div key={`${item.label}-${i}`}>
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
<div className="flex min-w-0 items-center gap-2">
<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] }}
/>
<span className="text-grayScale-600">{item.label}</span>
<span className="truncate text-grayScale-600" title={displayLabel}>
{displayLabel}
</span>
</div>
<span className="font-semibold text-grayScale-700">
{item.count.toLocaleString()}
@ -350,6 +364,7 @@ export function AnalyticsPage() {
}
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
const subscriptionMetrics = getSubscriptionMetrics(subscriptions)
const seriesPeriodLabel = getSeriesPeriodLabel(dashboard.date_filter)
const lms = courses.lms
const examPrep = courses.exam_prep
@ -478,10 +493,10 @@ export function AnalyticsPage() {
trend={users.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
icon={BadgeCheck}
label="Active Subscriptions"
value={formatNumber(subscriptions.active_subscriptions)}
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`}
icon={CreditCard}
label="Total Subscriptions"
value={formatNumber(subscriptionMetrics.total)}
sub={`${subscriptionMetrics.active} active · ${subscriptionMetrics.inactive} inactive`}
trend={subscriptions.new_month > 0 ? "up" : "neutral"}
/>
<KpiCard
@ -628,12 +643,72 @@ export function AnalyticsPage() {
</ResponsiveContainer>
</CardContent>
</Card>
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} />
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} />
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} />
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} />
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} />
<div className="mt-4 space-y-6">
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Profile & demographics
</p>
<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>
</Section>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -549,7 +549,7 @@ export function NotificationsPage() {
<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 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>}
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
</div>

View 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>
)
}

View File

@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -15,6 +15,7 @@ import {
Trash2,
UserX,
UserCheck,
Mail,
} from "lucide-react"
import { Button } from "../../components/ui/button"
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 { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { teamRoleFromRbacRole } from "../../lib/teamRoles"
import { InviteTeamMemberDialog } from "./components/InviteTeamMemberDialog"
export function RolesListPage() {
const navigate = useNavigate()
@ -83,6 +86,8 @@ export function RolesListPage() {
const [permSearch, setPermSearch] = useState("")
const [savingPermissions, setSavingPermissions] = useState(false)
const [inviteForRole, setInviteForRole] = useState<Role | null>(null)
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
@ -465,6 +470,18 @@ export function RolesListPage() {
</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">
<Button
type="button"
@ -966,6 +983,17 @@ export function RolesListPage() {
</div>
</DialogContent>
</Dialog>
<InviteTeamMemberDialog
open={inviteForRole !== null}
onOpenChange={(open) => {
if (!open) setInviteForRole(null)
}}
presetTeamRole={
inviteForRole ? teamRoleFromRbacRole(inviteForRole) : undefined
}
presetRoleLabel={inviteForRole?.name}
/>
</div>
)
}

View 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>
)
}

View File

@ -26,7 +26,12 @@ export interface DashboardUsers {
by_role: LabelCount[]
by_status: 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[]
registrations_last_30_days: DateCount[]
}

View 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 }

View 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
}