Compare commits

..

3 Commits

Author SHA1 Message Date
1014f4a72f feat(admin): notification details, question type library, and schema UX
Wire GET /notifications/:id for topbar and notifications page detail views, harden notification WebSocket lifecycle, paginate question type and app version lists from API, and expand dynamic question type schema labels and slot editing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 05:44:09 -07:00
92a2fab833 feat(admin): dynamic content flows, cleaner UI copy, and table pagination
Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:34:39 -07:00
2c3f0da6f7 feat(admin): payments, settings tabs, theme, and navigation refresh
Add admin payments with status, provider, and plan category filters. Introduce app versions and subscription plan management in settings, change-password security flow, and dark theme support. Reorganize sidebar, improve activity log actor details, analytics, and related UI polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 06:54:58 -07:00
109 changed files with 7272 additions and 1289 deletions

View File

@ -5,6 +5,27 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yimaru-admin</title>
<script>
(function () {
var key = "yimaru-admin-theme";
var stored = localStorage.getItem(key);
var root = document.documentElement;
var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
var resolved =
stored === "dark"
? "dark"
: stored === "system"
? systemDark
? "dark"
: "light"
: "light";
root.classList.remove("dark");
if (resolved === "dark") root.classList.add("dark");
root.dataset.theme = resolved;
root.dataset.themePreference = stored || "light";
root.style.colorScheme = resolved;
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@ -1,9 +1,29 @@
import { useEffect } from 'react'
import { Toaster } from 'sonner'
import { AppRoutes } from './app/AppRoutes'
import { useTheme } from './contexts/ThemeContext'
const SESSION_KEY = 'yimaru_session_active'
function AppToaster() {
const { resolvedTheme } = useTheme()
return (
<Toaster
position="top-center"
theme={resolvedTheme}
toastOptions={{
className: 'font-sans',
style: {
padding: '14px 20px',
borderRadius: '12px',
fontSize: '14px',
},
}}
richColors
/>
)
}
export default function App() {
useEffect(() => {
if (!sessionStorage.getItem(SESSION_KEY)) {
@ -18,18 +38,7 @@ export default function App() {
return (
<>
<AppRoutes />
<Toaster
position="top-center"
toastOptions={{
className: 'font-sans',
style: {
padding: '14px 20px',
borderRadius: '12px',
fontSize: '14px',
},
}}
richColors
/>
<AppToaster />
</>
)
}

View File

@ -143,6 +143,7 @@ function normalizeDashboardUsers(raw: unknown, root?: Record<string, unknown>):
by_knowledge_level: asLabelCounts(
pickField(u, "by_knowledge_level", "byKnowledgeLevel", "ByKnowledgeLevel"),
),
by_country: asLabelCounts(pickField(u, "by_country", "byCountry", "ByCountry")),
by_region: asLabelCounts(pickField(u, "by_region", "byRegion", "ByRegion")),
registrations_last_30_days: asDateCounts(
pickField(

118
src/api/app-versions.api.ts Normal file
View File

@ -0,0 +1,118 @@
import http from "./http"
import { DEFAULT_TABLE_PAGE_SIZE } from "../lib/tablePagination"
import type {
AppVersion,
AppVersionMutationResponse,
AppVersionsListData,
AppVersionsListResponse,
CreateAppVersionPayload,
UpdateAppVersionPayload,
} from "../types/app-version.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function normalizeAppVersion(raw: unknown): AppVersion | null {
if (!isRecord(raw)) return null
const id = Number(raw.id)
if (!Number.isFinite(id)) return null
return {
id,
platform: String(raw.platform ?? ""),
version_name: String(raw.version_name ?? ""),
version_code: Number(raw.version_code ?? 0),
update_type: String(raw.update_type ?? ""),
release_notes: String(raw.release_notes ?? ""),
store_url: String(raw.store_url ?? ""),
min_supported_version_code: Number(raw.min_supported_version_code ?? 0),
status: String(raw.status ?? ""),
created_at: String(raw.created_at ?? ""),
}
}
export function parseAppVersionsList(body: unknown): AppVersionsListData {
const empty: AppVersionsListData = { versions: [], total_count: 0 }
if (isRecord(body)) {
const data = body.data
if (isRecord(data) && Array.isArray(data.versions)) {
const versions = data.versions
.map(normalizeAppVersion)
.filter((v): v is AppVersion => v !== null)
const total_count = Number(data.total_count ?? versions.length)
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
}
if (Array.isArray(data)) {
const versions = data.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
return { versions, total_count: versions.length }
}
if (Array.isArray(body.versions)) {
const versions = body.versions
.map(normalizeAppVersion)
.filter((v): v is AppVersion => v !== null)
const total_count = Number(body.total_count ?? versions.length)
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
}
}
if (Array.isArray(body)) {
const versions = body.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
return { versions, total_count: versions.length }
}
return empty
}
export function parseAppVersionMutation(body: unknown): AppVersion | null {
if (isRecord(body) && body.data != null) {
return normalizeAppVersion(body.data)
}
return normalizeAppVersion(body)
}
export type GetAppVersionsParams = {
limit?: number
offset?: number
}
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
const limit = params.limit ?? DEFAULT_TABLE_PAGE_SIZE
const offset = params.offset ?? 0
return http
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })
.then((res) => {
const parsed = parseAppVersionsList(res.data)
return {
...res,
data: parsed,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
})
}
function mutationResult(res: { data: unknown }) {
const version = parseAppVersionMutation(res.data)
return {
...res,
data: version,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
}
export const createAppVersion = (payload: CreateAppVersionPayload) =>
http
.post<AppVersionMutationResponse>("/admin/app-versions", payload)
.then(mutationResult)
export const updateAppVersion = (id: number, payload: UpdateAppVersionPayload) =>
http
.put<AppVersionMutationResponse>(`/admin/app-versions/${id}`, payload)
.then(mutationResult)
export const deleteAppVersion = (id: number) =>
http.delete<{ message?: string }>(`/admin/app-versions/${id}`).then((res) => ({
...res,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}))

View File

@ -1,6 +1,6 @@
import http from "./http"
export type UploadMediaType = "image" | "audio" | "video"
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
export type UploadProvider = "MINIO" | "VIMEO"
export interface UploadMediaResponse {
@ -121,6 +121,8 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO
})
: uploadMediaFile("video", fileOrUrl, options)
export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file)
export const resolveFileUrl = (key: string) =>
http.get<ResolveFileUrlResponse>("/files/url", {
params: { key },

View File

@ -1,35 +1,125 @@
import http from "./http";
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
import http from "./http"
import type {
GetNotificationsResponse,
Notification,
UnreadCountResponse,
} from "../types/notification.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function unwrapEnvelopeData(body: unknown): unknown {
if (!isRecord(body)) return body
if ("data" in body || "Data" in body) {
return body.data ?? body.Data
}
return body
}
function normalizePayload(raw: unknown): Notification["payload"] {
if (!isRecord(raw)) {
return { tags: null }
}
const tags = Array.isArray(raw.tags)
? raw.tags.filter((tag): tag is string => typeof tag === "string" && tag.length > 0)
: null
return {
headline: raw.headline != null ? String(raw.headline) : undefined,
title: raw.title != null ? String(raw.title) : undefined,
message: raw.message != null ? String(raw.message) : undefined,
body: raw.body != null ? String(raw.body) : undefined,
tags,
}
}
export function normalizeNotification(raw: unknown): Notification | null {
if (!isRecord(raw)) return null
const id = String(raw.id ?? "")
if (!id) return null
return {
id,
recipient_id: Number(raw.recipient_id ?? 0),
receiver_type: raw.receiver_type != null ? String(raw.receiver_type) : undefined,
type: String(raw.type ?? ""),
level: String(raw.level ?? ""),
error_severity: String(raw.error_severity ?? ""),
reciever: String(raw.reciever ?? ""),
is_read: Boolean(raw.is_read),
delivery_status: String(raw.delivery_status ?? ""),
delivery_channel: String(raw.delivery_channel ?? ""),
payload: normalizePayload(raw.payload),
timestamp: String(raw.timestamp ?? ""),
expires: String(raw.expires ?? ""),
image: String(raw.image ?? ""),
}
}
function parseNotificationsListData(body: unknown, limit: number, offset: number): GetNotificationsResponse {
const inner = unwrapEnvelopeData(body)
if (!isRecord(inner)) {
return { notifications: [], total_count: 0, limit, offset }
}
const rows = Array.isArray(inner.notifications) ? inner.notifications : []
const notifications = rows
.map(normalizeNotification)
.filter((n): n is Notification => n !== null)
return {
notifications,
total_count: Number(inner.total_count ?? notifications.length),
limit: Number(inner.limit ?? limit),
offset: Number(inner.offset ?? offset),
}
}
function parseUnreadCount(body: unknown): UnreadCountResponse {
const inner = unwrapEnvelopeData(body)
if (!isRecord(inner)) return { unread: 0 }
return { unread: Number(inner.unread ?? 0) }
}
export const getNotifications = (limit = 10, offset = 0) =>
http.get<GetNotificationsResponse>("/notifications", {
params: { limit, offset },
});
http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
...res,
data: parseNotificationsListData(res.data, limit, offset),
}))
export const getNotificationById = (id: string) =>
http.get<unknown>(`/notifications/${id}`).then((res) => ({
...res,
data: normalizeNotification(unwrapEnvelopeData(res.data)),
}))
export const getUnreadCount = () =>
http.get<UnreadCountResponse>("/notifications/unread");
http.get<unknown>("/notifications/unread").then((res) => ({
...res,
data: parseUnreadCount(res.data),
}))
export const markAsRead = (id: string) =>
http.patch(`/notifications/${id}/read`);
http.patch(`/notifications/${id}/read`)
export const markAsUnread = (id: string) =>
http.patch(`/notifications/${id}/unread`);
http.patch(`/notifications/${id}/unread`)
export const markAllRead = () =>
http.post("/notifications/mark-all-read");
http.post("/notifications/mark-all-read")
export const markAllUnread = () =>
http.post("/notifications/mark-all-unread");
http.post("/notifications/mark-all-unread")
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
http.post("/notifications/bulk-sms", data);
http.post("/notifications/bulk-sms", data)
export const sendBulkEmail = (formData: FormData) =>
http.post("/notifications/bulk-email", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
})
export const sendBulkPush = (formData: FormData) =>
http.post("/notifications/bulk-push", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
})

98
src/api/payments.api.ts Normal file
View File

@ -0,0 +1,98 @@
import http from "./http"
import type { GetPaymentsParams, Payment, PaymentsListData, PaymentsListResponse } from "../types/payment.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function normalizePayment(raw: unknown): Payment | null {
if (!isRecord(raw)) return null
const id = Number(raw.id)
if (!Number.isFinite(id)) return null
const paid_at = raw.paid_at
const expires_at = raw.expires_at
return {
id,
user_id: Number(raw.user_id ?? 0),
plan_id: Number(raw.plan_id ?? 0),
subscription_id: Number(raw.subscription_id ?? 0),
session_id: String(raw.session_id ?? ""),
transaction_id: String(raw.transaction_id ?? ""),
nonce: String(raw.nonce ?? ""),
amount: Number(raw.amount ?? 0),
currency: String(raw.currency ?? "ETB"),
payment_method: String(raw.payment_method ?? ""),
status: String(raw.status ?? ""),
payment_url: String(raw.payment_url ?? ""),
plan_name: String(raw.plan_name ?? ""),
plan_category: String(raw.plan_category ?? ""),
user_email: String(raw.user_email ?? ""),
user_first_name: String(raw.user_first_name ?? ""),
user_last_name: String(raw.user_last_name ?? ""),
paid_at: paid_at == null || paid_at === "" ? null : String(paid_at),
expires_at: expires_at == null || expires_at === "" ? null : String(expires_at),
created_at: String(raw.created_at ?? ""),
updated_at: String(raw.updated_at ?? ""),
}
}
export function parsePaymentsList(body: unknown): PaymentsListData {
const empty: PaymentsListData = {
payments: [],
total_count: 0,
limit: 0,
offset: 0,
}
if (isRecord(body)) {
const data = body.data
if (isRecord(data) && Array.isArray(data.payments)) {
const payments = data.payments
.map(normalizePayment)
.filter((p): p is Payment => p !== null)
const total_count = Number(data.total_count ?? payments.length)
const limit = Number(data.limit ?? payments.length)
const offset = Number(data.offset ?? 0)
return {
payments,
total_count: Number.isFinite(total_count) ? total_count : payments.length,
limit: Number.isFinite(limit) ? limit : payments.length,
offset: Number.isFinite(offset) ? offset : 0,
}
}
if (Array.isArray(data)) {
const payments = data.map(normalizePayment).filter((p): p is Payment => p !== null)
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
}
}
if (Array.isArray(body)) {
const payments = body.map(normalizePayment).filter((p): p is Payment => p !== null)
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
}
return empty
}
function buildQueryParams(params: GetPaymentsParams): Record<string, string | number> {
const query: Record<string, string | number> = {
limit: Math.min(100, Math.max(1, params.limit ?? 20)),
offset: Math.max(0, params.offset ?? 0),
}
if (params.status?.trim()) query.status = params.status.trim()
if (params.provider?.trim()) query.provider = params.provider.trim()
if (params.plan_category?.trim()) query.plan_category = params.plan_category.trim()
return query
}
export const getPayments = (params: GetPaymentsParams = {}) =>
http.get<PaymentsListResponse>("/admin/payments", { params: buildQueryParams(params) }).then((res) => {
const parsed = parsePaymentsList(res.data)
return {
...res,
data: parsed,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
})

View File

@ -261,6 +261,40 @@ export function extractDefinitionMutationId(res: { data?: unknown }): number | u
/** @deprecated use extractDefinitionMutationId */
export const extractCreatedDefinitionId = extractDefinitionMutationId
export interface QuestionTypeDefinitionsListParams {
include_system?: boolean
status?: string
limit?: number
offset?: number
}
export interface QuestionTypeDefinitionsListResult {
definitions: QuestionTypeDefinition[]
total_count?: number
}
function parseListTotalCount(body: unknown): number | undefined {
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined
const o = body as Record<string, unknown>
const direct = Number(o.total_count ?? o.TotalCount ?? o.totalCount)
if (Number.isFinite(direct) && direct >= 0) return direct
const meta = o.metadata ?? o.Metadata
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
const m = meta as Record<string, unknown>
const fromMeta = Number(m.total_count ?? m.TotalCount ?? m.totalCount)
if (Number.isFinite(fromMeta) && fromMeta >= 0) return fromMeta
}
const data = o.data ?? o.Data
if (data && typeof data === "object" && !Array.isArray(data)) {
return parseListTotalCount(data)
}
return undefined
}
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
if (!payload) return []
if (Array.isArray(payload)) {
@ -270,30 +304,32 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[]
}
if (typeof payload === "object" && payload !== null) {
const o = payload as Record<string, unknown>
const inner = o.definitions ?? o.items ?? o.rows ?? o.Definitions
const inner =
o.question_type_definitions ??
o.QuestionTypeDefinitions ??
o.definitions ??
o.items ??
o.rows ??
o.Definitions
if (Array.isArray(inner)) return parseDefinitionsList(inner)
if (inner && typeof inner === "object") return parseDefinitionsList(inner)
if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner)
const data = o.data ?? o.Data
if (Array.isArray(data)) return parseDefinitionsList(data)
if (data && typeof data === "object") {
const single = normalizeTypeDefinitionFromApi(data)
return single ? [single] : []
}
if (data && typeof data === "object") return parseDefinitionsList(data)
const single = normalizeTypeDefinitionFromApi(payload)
return single ? [single] : []
}
return []
}
export async function getQuestionTypeDefinitions(params?: {
include_system?: boolean
status?: string
limit?: number
offset?: number
}) {
export async function getQuestionTypeDefinitions(
params?: QuestionTypeDefinitionsListParams,
): Promise<QuestionTypeDefinitionsListResult> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data
return parseDefinitionsList(raw)
const definitions = parseDefinitionsList(raw)
const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data)
return total_count != null ? { definitions, total_count } : { definitions }
}
/**

View File

@ -1,11 +1,84 @@
import http from "./http"
import type { SubscriptionPlansListResponse, SubscriptionPlan } from "../types/subscription.types"
import type {
CreateSubscriptionPlanPayload,
SubscriptionPlan,
SubscriptionPlanMutationResponse,
SubscriptionPlansListResponse,
UpdateSubscriptionPlanPayload,
} from "../types/subscription.types"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
function normalizeSubscriptionPlan(raw: unknown): SubscriptionPlan | null {
if (!isRecord(raw)) return null
const id = Number(raw.id)
if (!Number.isFinite(id)) return null
return {
id,
name: String(raw.name ?? ""),
description: String(raw.description ?? ""),
category: String(raw.category ?? ""),
duration_value: Number(raw.duration_value ?? 0),
duration_unit: String(raw.duration_unit ?? "MONTH"),
price: Number(raw.price ?? 0),
currency: String(raw.currency ?? "ETB"),
is_active: Boolean(raw.is_active ?? true),
created_at: String(raw.created_at ?? ""),
}
}
export function parseSubscriptionPlansList(body: unknown): SubscriptionPlan[] {
if (Array.isArray(body)) {
return body.map(normalizeSubscriptionPlan).filter((p): p is SubscriptionPlan => p !== null)
}
if (isRecord(body) && Array.isArray(body.data)) {
return body.data
.map(normalizeSubscriptionPlan)
.filter((p): p is SubscriptionPlan => p !== null)
}
return []
}
export function parseSubscriptionPlanMutation(body: unknown): SubscriptionPlan | null {
if (isRecord(body) && body.data != null) {
return normalizeSubscriptionPlan(body.data)
}
return normalizeSubscriptionPlan(body)
}
export const getSubscriptionPlans = () =>
http.get<SubscriptionPlansListResponse>("/subscription-plans").then((res) => ({
http.get<SubscriptionPlansListResponse | SubscriptionPlan[]>("/subscription-plans").then((res) => {
const plans = parseSubscriptionPlansList(res.data)
return {
...res,
data: plans,
}
})
function mutationResult(res: { data: unknown }) {
const plan = parseSubscriptionPlanMutation(res.data)
return {
...res,
data: {
...res.data,
data: Array.isArray(res.data?.data) ? res.data.data : ([] as SubscriptionPlan[]),
},
data: plan,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}
}
export const createSubscriptionPlan = (payload: CreateSubscriptionPlanPayload) =>
http
.post<SubscriptionPlanMutationResponse | SubscriptionPlan>("/subscription-plans", payload)
.then(mutationResult)
export const updateSubscriptionPlan = (id: number, payload: UpdateSubscriptionPlanPayload) =>
http
.put<SubscriptionPlanMutationResponse | SubscriptionPlan>(`/subscription-plans/${id}`, payload)
.then(mutationResult)
export const deleteSubscriptionPlan = (id: number) =>
http.delete<{ message?: string }>(`/subscription-plans/${id}`).then((res) => ({
...res,
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
}))

View File

@ -7,6 +7,8 @@ import type {
VerifyInvitationResponse,
} from "../types/teamInvitation.types"
import type {
ChangeTeamMemberPasswordRequest,
ChangeTeamMemberPasswordResponse,
GetTeamMembersResponse,
GetTeamMemberResponse,
CreateTeamMemberRequest,
@ -33,6 +35,10 @@ export const updateTeamMemberStatus = (id: number, status: string) =>
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
http.put(`/team/members/${id}`, data)
/** POST /team/members/:id/change-password — change the signed-in member's password. */
export const changeTeamMemberPassword = (id: number, data: ChangeTeamMemberPasswordRequest) =>
http.post<ChangeTeamMemberPasswordResponse>(`/team/members/${id}/change-password`, data)
/** POST /team/members/invite — send invitation email (permission: team.members.invite). */
export const inviteTeamMember = (data: InviteTeamMemberRequest) =>
http.post<InviteTeamMemberResponse>("/team/members/invite", data)

View File

@ -54,6 +54,7 @@ import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLan
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
import { UserLogPage } from "../pages/user-log/UserLogPage";
import { IssuesPage } from "../pages/issues/IssuesPage";
import { PaymentsPage } from "../pages/payments/PaymentsPage";
import { ProfilePage } from "../pages/ProfilePage";
import { SettingsPage } from "../pages/SettingsPage";
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
@ -255,6 +256,7 @@ export function AppRoutes() {
path="/notifications/create"
element={<CreateNotificationPage />}
/>
<Route path="/payments" element={<PaymentsPage />} />
<Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />

View File

@ -6,15 +6,18 @@ import {
type ChangeEvent,
type DragEvent,
} from "react"
import { CloudUpload, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { CloudUpload, FileText, Image as ImageIcon, Mic, Pause, Play, X } from "lucide-react"
import { toast } from "sonner"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils"
import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicTableBuilder } from "./DynamicTableBuilder"
import { slotLabel } from "../../lib/schemaSlotLabel"
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
@ -28,13 +31,43 @@ export interface DynamicSchemaSlotRow {
required?: boolean
}
function slotMediaMode(kind: string): "image" | "audio" | "text" {
function slotMediaMode(
kind: string,
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
const u = kind.trim().toUpperCase()
if (u === "IMAGE") return "image"
if (u.startsWith("AUDIO")) return "audio"
if (u === "TABLE") return "table"
if (u === "PDF_ATTACHMENT" || u === "PDF_UPLOAD") return "pdf"
if (u === "PREP_TIME" || u === "ANSWER_TIMER") return "seconds"
if (u === "AUDIO_PROMPT" || u === "AUDIO_CLIP" || u === "AUDIO_RESPONSE") return "audio"
return "text"
}
function readSecondsFieldValue(raw: string): string {
const t = raw.trim()
if (!t) return ""
if (/^\d+$/.test(t)) return t
try {
const parsed = JSON.parse(t) as unknown
if (typeof parsed === "number" && Number.isFinite(parsed)) return String(parsed)
if (parsed && typeof parsed === "object" && "seconds" in parsed) {
const seconds = (parsed as { seconds?: unknown }).seconds
if (typeof seconds === "number" && Number.isFinite(seconds)) return String(seconds)
}
} catch {
/* keep raw */
}
return t
}
function writeSecondsFieldValue(raw: string): string {
const t = raw.trim()
if (!t) return ""
const n = Number.parseInt(t, 10)
if (!Number.isFinite(n) || n < 0) return ""
return JSON.stringify({ seconds: n })
}
function isHttpUrl(s: string): boolean {
return /^https?:\/\//i.test(s.trim())
}
@ -137,7 +170,9 @@ function DynamicImageSlot({
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
{slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div
@ -407,7 +442,9 @@ function DynamicAudioSlot({
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
{slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
<div className="min-h-[100px] space-y-2 rounded-lg border border-grayScale-200 bg-grayScale-50/40 p-2.5 lg:min-h-[140px]">
@ -537,6 +574,105 @@ export interface DynamicSchemaSlotFieldProps {
disabled?: boolean
}
function DynamicPdfSlot({
value,
onChange,
disabled,
slotLabel,
slotMeta,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
slotMeta: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const processFile = useCallback(
async (file: File) => {
if (disabled || uploading) return
const ext = file.name.split(".").pop()?.toLowerCase() ?? ""
if (ext !== "pdf") {
toast.error("Only PDF files are allowed")
return
}
if (file.size > 25 * 1024 * 1024) {
toast.error("PDF is too large", { description: "Maximum size is 25 MB." })
return
}
setUploading(true)
try {
const res = await uploadPdfFile(file)
const url = res.data?.data?.url?.trim()
if (!url) throw new Error("Upload did not return a URL")
onChange(url)
toast.success("PDF uploaded")
} catch (e) {
console.error(e)
toast.error("Failed to upload PDF")
} finally {
setUploading(false)
}
},
[disabled, onChange, uploading],
)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
{slotMeta ? (
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
) : null}
</div>
<input
ref={fileInputRef}
type="file"
accept="application/pdf,.pdf"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
e.target.value = ""
if (file) void processFile(file)
}}
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
{uploading ? <SpinnerIcon className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
Upload PDF
</Button>
{value.trim() ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={() => onChange("")}
>
Clear
</Button>
) : null}
</div>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="https://… or upload above"
className="h-11 rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled || uploading}
/>
</div>
)
}
export function DynamicSchemaSlotField({
row,
value,
@ -544,24 +680,52 @@ export function DynamicSchemaSlotField({
disabled = false,
}: DynamicSchemaSlotFieldProps) {
const mode = slotMediaMode(row.kind)
const baseLabel =
row.label?.trim() ||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind)
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
const slotMeta = `${row.id} · ${row.kind}`
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
if (mode === "table") {
return (
<DynamicTableBuilder
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
if (mode === "seconds") {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700">{fieldLabel}</label>
<Input
type="number"
min={0}
step={1}
value={readSecondsFieldValue(value)}
onChange={(e) => onChange(writeSecondsFieldValue(e.target.value))}
placeholder="e.g. 30"
className="h-11 max-w-[200px] rounded-lg border-grayScale-200"
disabled={disabled}
/>
<p className="text-[11px] text-grayScale-500">Stored as seconds (e.g. {`{"seconds": 30}`}).</p>
</div>
)
}
if (mode === "text") {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<label className="text-sm font-medium text-grayScale-700">{fieldLabel}</label>
<Textarea
rows={3}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="URL, plain text, or JSON object"
placeholder={
row.kind === "OPTION"
? '{"options":[{"id":"a","text":"…","is_correct":true}]}'
: "URL, plain text, or JSON object"
}
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
disabled={disabled}
/>
@ -575,8 +739,20 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
if (mode === "pdf") {
return (
<DynamicPdfSlot
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}
@ -586,8 +762,8 @@ export function DynamicSchemaSlotField({
value={value}
onChange={onChange}
disabled={disabled}
slotLabel={slotLabel}
slotMeta={slotMeta}
slotLabel={fieldLabel}
slotMeta=""
/>
)
}

View File

@ -0,0 +1,250 @@
import { useMemo } from "react"
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { cn } from "../../lib/utils"
import {
createEmptyTable,
parseTableSlotValue,
serializeTableSlotValue,
type DynamicTableValue,
} from "../../lib/dynamicTableValue"
export type DynamicTableBuilderProps = {
value: string
onChange: (next: string) => void
disabled?: boolean
slotLabel: string
slotMeta: string
}
function normalizeTable(table: DynamicTableValue): DynamicTableValue {
const columns =
table.columns.length > 0
? table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
: ["Column 1"]
const colCount = columns.length
const rows =
table.rows.length > 0
? table.rows.map((row) => {
const cells = [...row]
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
: [Array(colCount).fill("")]
return { columns, rows }
}
export function DynamicTableBuilder({
value,
onChange,
disabled = false,
slotLabel,
slotMeta,
}: DynamicTableBuilderProps) {
const table = useMemo(() => normalizeTable(parseTableSlotValue(value)), [value])
const commit = (next: DynamicTableValue) => {
onChange(serializeTableSlotValue(normalizeTable(next)))
}
const updateColumn = (colIndex: number, text: string) => {
const columns = [...table.columns]
columns[colIndex] = text
commit({ columns, rows: table.rows })
}
const updateCell = (rowIndex: number, colIndex: number, text: string) => {
const rows = table.rows.map((r) => [...r])
rows[rowIndex][colIndex] = text
commit({ columns: table.columns, rows })
}
const addColumn = () => {
const columns = [...table.columns, `Column ${table.columns.length + 1}`]
const rows = table.rows.map((row) => [...row, ""])
commit({ columns, rows })
}
const removeColumn = (colIndex: number) => {
if (table.columns.length <= 1) return
const columns = table.columns.filter((_, i) => i !== colIndex)
const rows = table.rows.map((row) => row.filter((_, i) => i !== colIndex))
commit({ columns, rows })
}
const addRow = () => {
const rows = [...table.rows, Array(table.columns.length).fill("")]
commit({ columns: table.columns, rows })
}
const removeRow = (rowIndex: number) => {
if (table.rows.length <= 1) return
const rows = table.rows.filter((_, i) => i !== rowIndex)
commit({ columns: table.columns, rows })
}
const resetTable = () => {
commit(createEmptyTable(2, 1))
}
const previewColumns = table.columns.map((c, i) => c.trim() || `Column ${i + 1}`)
const previewRows = table.rows.map((row) =>
row.map((cell, ci) => cell.trim() || ""),
)
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
</div>
<p className="text-xs text-grayScale-500">
Build the reference table learners will see with the question.
</p>
<div className="overflow-x-auto rounded-xl border border-grayScale-200 bg-white">
<table className="w-full min-w-[320px] border-collapse text-sm">
<thead>
<tr className="bg-grayScale-50/90">
{table.columns.map((col, colIndex) => (
<th
key={`col-${colIndex}`}
className="border-b border-r border-grayScale-200 p-1.5 align-top last:border-r-0"
>
<div className="flex min-w-[100px] items-start gap-1">
<Input
value={col}
disabled={disabled}
onChange={(e) => updateColumn(colIndex, e.target.value)}
placeholder={`Column ${colIndex + 1}`}
className="h-9 border-grayScale-200 bg-white text-xs font-semibold"
/>
{table.columns.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="h-9 w-9 shrink-0 p-0 text-grayScale-400 hover:text-red-600"
aria-label={`Remove column ${colIndex + 1}`}
onClick={() => removeColumn(colIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</th>
))}
<th className="w-10 border-b border-grayScale-200 bg-grayScale-50/90 p-1" />
</tr>
</thead>
<tbody>
{table.rows.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="group">
{row.map((cell, colIndex) => (
<td
key={`cell-${rowIndex}-${colIndex}`}
className="border-b border-r border-grayScale-100 p-1.5 last:border-r-0"
>
<Input
value={cell}
disabled={disabled}
onChange={(e) => updateCell(rowIndex, colIndex, e.target.value)}
placeholder="Cell value"
className="h-9 border-grayScale-200 bg-[#F8FAFC] text-sm"
/>
</td>
))}
<td className="border-b border-grayScale-100 p-1 align-middle">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || table.rows.length <= 1}
className="h-9 w-9 p-0 text-grayScale-400 hover:text-red-600 disabled:opacity-30"
aria-label={`Remove row ${rowIndex + 1}`}
onClick={() => removeRow(rowIndex)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addColumn}
>
<Plus className="h-3.5 w-3.5" />
Add column
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-1.5"
onClick={addRow}
>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
onClick={resetTable}
>
Reset table
</Button>
</div>
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/60 p-4">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Learner preview
</p>
<div className="overflow-x-auto rounded-lg border border-grayScale-200 bg-white">
<table className={cn("w-full min-w-[240px] border-collapse text-sm text-grayScale-800")}>
<thead>
<tr className="bg-brand-50/80">
{previewColumns.map((col, i) => (
<th
key={`preview-h-${i}`}
className="border border-grayScale-200 px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-grayScale-700"
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{previewRows.map((row, ri) => (
<tr key={`preview-r-${ri}`} className={ri % 2 === 0 ? "bg-white" : "bg-grayScale-50/50"}>
{row.map((cell, ci) => (
<td
key={`preview-c-${ri}-${ci}`}
className="border border-grayScale-100 px-3 py-2 text-sm"
>
{cell || <span className="text-grayScale-300"></span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@ -636,8 +636,8 @@ export function PracticeQuestionEditorFields({
setDefinitionsLoading(true)
;(async () => {
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
} finally {
@ -778,9 +778,8 @@ export function PracticeQuestionEditorFields({
{value.questionType === "DYNAMIC" && (
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
<p className="text-xs leading-snug text-grayScale-600 sm:text-sm">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots: drop file or paste URL
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other
slots: text or JSON.
Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
fields accept text or structured values where noted.
</p>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">

View File

@ -0,0 +1,169 @@
import { Badge } from "../ui/badge"
import { Button } from "../ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { SpinnerIcon } from "../ui/spinner-icon"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationDateTime,
formatNotificationTimestamp,
formatNotificationTypeLabel,
getNotificationLevelBadge,
isMeaningfulExpiry,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
type NotificationDetailDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
notification: Notification | null
loading?: boolean
error?: boolean
onRetry?: () => void
}
export function NotificationDetailDialog({
open,
onOpenChange,
notification,
loading = false,
error = false,
onRetry,
}: NotificationDetailDialogProps) {
const config = notification
? NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
: DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading notification</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<p className="text-sm font-medium text-grayScale-700">Could not load notification</p>
{onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry}>
Try again
</Button>
) : null}
</div>
) : notification ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-lg ${config.bg} ${config.color}`}
>
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-base">
{getNotificationTitle(notification) || "Notification"}
</span>
</DialogTitle>
<DialogDescription>
Sent via {notification.delivery_channel || "in-app"} ·{" "}
{formatNotificationTimestamp(notification.timestamp)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{notification.image ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-grayScale-50">
<img
src={notification.image}
alt=""
className="max-h-48 w-full object-cover"
/>
</div>
) : null}
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm leading-relaxed text-grayScale-600">
{getNotificationMessage(notification) || "No message content."}
</p>
</div>
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
<div>
<p className="text-grayScale-400">Type</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationTypeLabel(notification.type)}
</p>
</div>
<div>
<p className="text-grayScale-400">Level</p>
<div className="mt-0.5">
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px]">
{notification.level || "—"}
</Badge>
</div>
</div>
<div>
<p className="text-grayScale-400">Channel</p>
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
{notification.delivery_channel || "—"}
</p>
</div>
<div>
<p className="text-grayScale-400">Delivery status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{notification.delivery_status || "—"}
</p>
</div>
<div>
<p className="text-grayScale-400">Read status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{notification.is_read ? "Read" : "Unread"}
</p>
</div>
{notification.receiver_type ? (
<div>
<p className="text-grayScale-400">Receiver</p>
<p className="mt-0.5 font-medium capitalize text-grayScale-700">
{notification.receiver_type}
</p>
</div>
) : null}
<div className="sm:col-span-2">
<p className="text-grayScale-400">Sent at</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationDateTime(notification.timestamp)}
</p>
</div>
{isMeaningfulExpiry(notification.expires) ? (
<div className="sm:col-span-2">
<p className="text-grayScale-400">Expires</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatNotificationDateTime(notification.expires)}
</p>
</div>
) : null}
</div>
{notification.payload.tags && notification.payload.tags.length > 0 ? (
<div className="flex flex-wrap gap-2">
{notification.payload.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{tag}
</Badge>
))}
</div>
) : null}
</div>
</>
) : null}
</DialogContent>
</Dialog>
)
}

View File

@ -6,12 +6,11 @@ import {
ChevronRight,
CircleAlert,
ClipboardList,
CreditCard,
LayoutDashboard,
LogOut,
Shield,
UserCircle2,
Users,
Users2,
Settings,
X,
} from "lucide-react";
@ -33,32 +32,71 @@ type NavGroupItem = {
kind: "group";
label: string;
basePath: string;
activePaths?: string[];
icon: ComponentType<{ className?: string }>;
children: { label: string; to: string; end?: boolean }[];
};
type NavEntry = NavLinkItem | NavGroupItem;
type NavSectionItem = {
kind: "section";
label: string;
};
type NavEntry = NavLinkItem | NavGroupItem | NavSectionItem;
const navEntries: NavEntry[] = [
{ kind: "section", label: "Overview" },
{ 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: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
{ kind: "section", label: "People" },
{
kind: "group",
label: "Users & access",
basePath: "/users",
activePaths: ["/users", "/roles", "/team"],
icon: Users,
children: [
{ label: "All users", to: "/users/list" },
{ label: "Roles", to: "/roles" },
{ label: "Team members", to: "/team" },
],
},
{ kind: "section", label: "Learning content" },
{
kind: "group",
label: "Content",
basePath: "/content",
activePaths: ["/content", "/new-content"],
icon: BookOpen,
children: [
{ label: "Manage practices", to: "/content", end: true },
{ label: "New content", to: "/new-content", end: true },
{ label: "Reorder structure", to: "/new-content/reorder" },
{ label: "Question types", to: "/new-content/question-types" },
],
},
{ kind: "section", label: "Communications" },
{
kind: "group",
label: "Notifications",
basePath: "/notifications",
icon: Bell,
children: [
{ label: "My Notifications", to: "/notifications", end: true },
{ label: "Email Templates", to: "/notifications/email-templates" },
{ label: "Inbox", to: "/notifications", end: true },
{ label: "Email templates", to: "/notifications/email-templates" },
{ label: "Send notification", to: "/notifications/create" },
],
},
{ 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: "section", label: "Operations" },
{ kind: "link", label: "Payments", to: "/payments", icon: CreditCard },
{ kind: "link", label: "User activity log", to: "/user-log", icon: ClipboardList },
{ kind: "link", label: "Issue reports", to: "/issues", icon: CircleAlert },
{ kind: "section", label: "Account" },
{ kind: "link", label: "Profile", to: "/profile", icon: UserCircle2 },
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
];
@ -162,19 +200,50 @@ export function Sidebar({
</button>
</div>
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
{navEntries.map((entry) => {
<nav className="mt-6 flex-1 space-y-0.5 overflow-y-auto">
{navEntries.map((entry, index) => {
if (entry.kind === "section") {
if (isCollapsed) {
return index > 0 ? (
<div
key={`section-gap-${entry.label}`}
className="mx-auto my-2 h-px w-6 bg-grayScale-200"
aria-hidden
/>
) : null;
}
return (
<p
key={`section-${entry.label}`}
className={cn(
"mb-1 px-3 pt-3 text-[10px] font-bold uppercase tracking-wider text-grayScale-400",
index === 0 && "pt-0",
)}
>
{entry.label}
</p>
);
}
if (entry.kind === "group") {
const isNotifications = entry.basePath === "/notifications";
return (
<SidebarNavGroup
key={entry.basePath}
label={entry.label}
icon={entry.icon}
basePath={entry.basePath}
activePaths={entry.activePaths}
children={entry.children}
isCollapsed={isCollapsed}
onNavigate={onClose}
trailing={!isCollapsed ? unreadBadge : collapsedUnreadDot}
trailing={
isNotifications
? !isCollapsed
? unreadBadge
: collapsedUnreadDot
: undefined
}
/>
);
}

View File

@ -13,6 +13,8 @@ type SidebarNavGroupProps = {
label: string;
icon: ComponentType<{ className?: string }>;
basePath: string;
/** When set, any matching prefix marks the group active (e.g. `/content` and `/new-content`). */
activePaths?: string[];
children: SidebarNavChild[];
isCollapsed: boolean;
onNavigate?: () => void;
@ -23,6 +25,7 @@ export function SidebarNavGroup({
label,
icon: Icon,
basePath,
activePaths,
children,
isCollapsed,
onNavigate,
@ -30,7 +33,8 @@ export function SidebarNavGroup({
}: SidebarNavGroupProps) {
const location = useLocation();
const panelId = useId();
const isSectionActive = location.pathname.startsWith(basePath);
const paths = activePaths?.length ? activePaths : [basePath];
const isSectionActive = paths.some((path) => location.pathname.startsWith(path));
const [expanded, setExpanded] = useState(isSectionActive);
useEffect(() => {

View File

@ -1,74 +1,32 @@
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import {
Bell,
BellOff,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
} from "lucide-react"
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "../ui/badge"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../ui/spinner-icon"
import { getNotificationById } from "../../api/notifications.api"
import { useNotifications } from "../../hooks/useNotifications"
import { NotificationDetailDialog } from "../notifications/NotificationDetailDialog"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationTimestamp,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
function formatTimestamp(ts: string) {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
function NotificationItem({
notification,
onOpen,
onMarkRead,
onMarkUnread,
}: {
notification: Notification
onOpen: (notification: Notification) => void
onMarkRead: (id: string) => void
onMarkUnread: (id: string) => void
}) {
const cfg = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const cfg = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = cfg.icon
return (
@ -77,31 +35,26 @@ function NotificationItem({
className={cn(
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
)}
onClick={() => {
if (!notification.is_read) onMarkRead(notification.id)
}}
onClick={() => onOpen(notification)}
>
{/* Unread dot */}
{!notification.is_read && (
<span className="absolute left-0.5 top-1/2 h-2 w-2 -translate-y-1/2 rounded-full bg-brand-500" />
)}
{/* Type icon */}
<span
className={cn(
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
cfg.bg
cfg.bg,
)}
>
<Icon className={cn("h-4 w-4", cfg.color)} />
</span>
{/* Content */}
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm leading-snug text-grayScale-900",
!notification.is_read && "font-semibold"
!notification.is_read && "font-semibold",
)}
>
{getNotificationTitle(notification) || "Notification"}
@ -110,11 +63,10 @@ function NotificationItem({
{getNotificationMessage(notification) || "No preview text available."}
</p>
<p className="mt-1 text-[11px] text-grayScale-600">
{formatTimestamp(notification.timestamp)}
{formatNotificationTimestamp(notification.timestamp)}
</p>
</div>
{/* Read / Unread toggle */}
<button
type="button"
className="hidden shrink-0 self-center rounded-md p-1.5 text-grayScale-400 hover:bg-grayScale-200 hover:text-grayScale-600 group-hover:block"
@ -140,6 +92,11 @@ function NotificationItem({
export function NotificationDropdown() {
const [open, setOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const navigate = useNavigate()
const {
@ -151,7 +108,40 @@ export function NotificationDropdown() {
markAllAsRead,
} = useNotifications()
// Click-outside handler
const loadNotificationDetail = useCallback(async (id: string, markReadIfNeeded: boolean) => {
setDetailLoading(true)
setDetailError(false)
setSelectedNotification(null)
setSelectedNotificationId(id)
setDetailOpen(true)
try {
const res = await getNotificationById(id)
if (!res.data) {
setDetailError(true)
toast.error("Notification not found")
return
}
setSelectedNotification(res.data)
if (markReadIfNeeded && !res.data.is_read) {
void markOneRead(id)
}
} catch {
setDetailError(true)
toast.error("Failed to load notification details")
} finally {
setDetailLoading(false)
}
}, [markOneRead])
const handleOpenNotification = useCallback(
(notification: Notification) => {
setOpen(false)
void loadNotificationDetail(notification.id, !notification.is_read)
},
[loadNotificationDetail],
)
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
@ -165,89 +155,98 @@ export function NotificationDropdown() {
}, [open])
return (
<div ref={containerRef} className="relative">
{/* Bell button */}
<button
type="button"
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
aria-label="Notifications"
onClick={() => setOpen((prev) => !prev)}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
<>
<div ref={containerRef} className="relative">
<button
type="button"
className="relative grid h-10 w-10 place-items-center rounded-full border bg-white text-grayScale-500 transition-colors hover:text-brand-600"
aria-label="Notifications"
onClick={() => setOpen((prev) => !prev)}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</button>
{/* Dropdown panel */}
{open && (
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-grayScale-800">
Notifications
</h3>
{open && (
<div className="animate-in fade-in-0 zoom-in-95 absolute right-0 top-12 z-50 w-[380px] rounded-xl bg-white shadow-lg ring-1 ring-black/5">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
{unreadCount > 0 && (
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
)}
</div>
{unreadCount > 0 && (
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
<button
type="button"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={markAllAsRead}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</button>
)}
</div>
{unreadCount > 0 && (
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<SpinnerIcon className="h-6 w-6" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
<BellOff className="h-8 w-8" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="p-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onOpen={handleOpenNotification}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
))}
</div>
)}
</div>
<div className="border-t px-4 py-2.5">
<button
type="button"
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-grayScale-500 transition-colors hover:bg-grayScale-100 hover:text-grayScale-700"
onClick={markAllAsRead}
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
onClick={() => {
setOpen(false)
navigate("/notifications")
}}
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
View all notifications
</button>
)}
</div>
</div>
)}
</div>
{/* Body */}
<div className="max-h-[480px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<SpinnerIcon className="h-6 w-6" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
<BellOff className="h-8 w-8" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className="p-1">
{notifications.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markOneRead}
onMarkUnread={markOneUnread}
/>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t px-4 py-2.5">
<button
type="button"
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
onClick={() => {
setOpen(false)
navigate("/notifications")
}}
>
View all notifications
</button>
</div>
</div>
)}
</div>
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId, false)
: undefined
}
/>
</>
)
}

View File

@ -9,7 +9,8 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-brand-600",
default: "bg-primary text-white hover:bg-brand-600 hover:text-white",
brand: "bg-brand-500 text-white hover:bg-brand-600 hover:text-white",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border bg-background hover:bg-grayScale-100",
ghost: "hover:bg-grayScale-100",

View File

@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 text-card-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
{...props}

View File

@ -9,7 +9,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-[6px] border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-[6px] border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
<div className="relative">
<select
className={cn(
"flex h-11 w-full appearance-none rounded-xl border border-grayScale-200 bg-white px-3 py-2 pr-8 text-sm text-grayScale-600 shadow-sm ring-offset-background transition hover:bg-grayScale-50 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-11 w-full appearance-none rounded-xl border border-input bg-grayScale-50 px-3 py-2 pr-8 text-sm text-foreground shadow-sm ring-offset-background transition hover:bg-grayScale-100 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@ -8,7 +8,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-lg border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}

View File

@ -0,0 +1,76 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import {
applyTheme,
getStoredTheme,
getSystemTheme,
resolveTheme,
THEME_STORAGE_KEY,
watchSystemTheme,
type ResolvedTheme,
type ThemeMode,
} from "../lib/theme"
type ThemeContextValue = {
theme: ThemeMode
resolvedTheme: ResolvedTheme
systemTheme: ResolvedTheme
setTheme: (mode: ThemeMode) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(() => getStoredTheme())
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
resolveTheme(getStoredTheme()),
)
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => getSystemTheme())
const setTheme = useCallback((mode: ThemeMode) => {
localStorage.setItem(THEME_STORAGE_KEY, mode)
setThemeState(mode)
setResolvedTheme(applyTheme(mode))
}, [])
useEffect(() => {
setResolvedTheme(applyTheme(theme))
}, [theme])
useEffect(() => {
return watchSystemTheme((next) => {
setSystemTheme(next)
if (theme === "system") {
setResolvedTheme(applyTheme("system"))
}
})
}, [theme])
const value = useMemo(
() => ({ theme, resolvedTheme, systemTheme, setTheme }),
[theme, resolvedTheme, systemTheme, setTheme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error("useTheme must be used within ThemeProvider")
}
return ctx
}
export function useThemeOptional(): ThemeContextValue | null {
return useContext(ThemeContext)
}
export { getSystemTheme }

View File

@ -3,12 +3,13 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
import type { Notification } from "../types/notification.types"
const MAX_DROPDOWN = 5
const RECONNECT_MS = 5000
function getWsUrl() {
const base = import.meta.env.VITE_API_BASE_URL as string
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
const token = localStorage.getItem("access_token") ?? ""
return `${wsBase}/ws/connect?token=${token}`
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
}
export function useNotifications() {
@ -18,6 +19,8 @@ export function useNotifications() {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const intentionalCloseRef = useRef(false)
const connectAttemptRef = useRef(0)
const dispatchUpdate = () => {
window.dispatchEvent(new Event("notifications-updated"))
@ -40,11 +43,37 @@ export function useNotifications() {
}
}, [])
const connectWs = useCallback(() => {
if (wsRef.current) {
wsRef.current.close()
const clearReconnectTimer = useCallback(() => {
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current)
reconnectTimer.current = null
}
}, [])
const disconnectWs = useCallback(
(intentional: boolean) => {
intentionalCloseRef.current = intentional
clearReconnectTimer()
const ws = wsRef.current
wsRef.current = null
if (!ws) return
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close()
}
},
[clearReconnectTimer],
)
const connectWs = useCallback(() => {
if (!mountedRef.current) return
const token = localStorage.getItem("access_token")?.trim()
if (!token) return
disconnectWs(true)
intentionalCloseRef.current = false
const attempt = ++connectAttemptRef.current
const ws = new WebSocket(getWsUrl())
wsRef.current = ws
@ -78,47 +107,45 @@ export function useNotifications() {
}
}
ws.onerror = () => {
ws.close()
}
ws.onclose = () => {
if (!mountedRef.current) return
if (connectAttemptRef.current !== attempt) return
if (wsRef.current === ws) wsRef.current = null
if (!mountedRef.current || intentionalCloseRef.current) return
clearReconnectTimer()
reconnectTimer.current = setTimeout(() => {
if (mountedRef.current) connectWs()
}, 5000)
}, RECONNECT_MS)
}
}, [])
}, [clearReconnectTimer, disconnectWs])
useEffect(() => {
mountedRef.current = true
intentionalCloseRef.current = false
fetchData()
connectWs()
return () => {
mountedRef.current = false
wsRef.current?.close()
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
disconnectWs(true)
}
}, [fetchData, connectWs])
}, [fetchData, connectWs, disconnectWs])
const markOneRead = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
)
setUnreadCount((prev) => Math.max(0, prev - 1))
dispatchUpdate()
try {
await markAsRead(id)
} catch {
// revert on failure
await fetchData()
}
}, [fetchData])
const markOneUnread = useCallback(async (id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n))
prev.map((n) => (n.id === id ? { ...n, is_read: false } : n)),
)
setUnreadCount((prev) => prev + 1)
dispatchUpdate()

View File

@ -5,7 +5,17 @@
@tailwind utilities;
@layer base {
:root {
html {
color-scheme: light;
--gs-50: #ffffff;
--gs-100: #f5f5f5;
--gs-200: #e0e0e0;
--gs-300: #bdbdbd;
--gs-400: #9e9e9e;
--gs-500: #757575;
--gs-600: #616161;
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.06);
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
@ -38,6 +48,46 @@
--radius: 14px;
}
html.dark {
color-scheme: dark;
--gs-50: #1c1c24;
--gs-100: #12121a;
--gs-200: #2e2e3a;
--gs-300: #454552;
--gs-400: #9a9aaa;
--gs-500: #b8b8c6;
--gs-600: #ececf2;
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.45);
--background: 240 10% 7%;
--foreground: 210 20% 96%;
--card: 240 8% 12%;
--card-foreground: 210 20% 96%;
--popover: 240 8% 12%;
--popover-foreground: 210 20% 96%;
--primary: 312 59% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 240 6% 18%;
--secondary-foreground: 210 20% 96%;
--muted: 240 6% 18%;
--muted-foreground: 240 5% 65%;
--accent: 240 6% 18%;
--accent-foreground: 210 20% 96%;
--destructive: 0 62% 50%;
--destructive-foreground: 0 0% 100%;
--border: 240 6% 22%;
--input: 240 6% 22%;
--ring: 312 59% 50%;
}
* {
@apply border-border;
}
@ -49,6 +99,61 @@
}
body {
@apply bg-grayScale-100 text-foreground font-sans antialiased;
@apply bg-grayScale-100 text-foreground font-sans antialiased transition-colors duration-200;
}
}
@layer components {
/*
* Brand scale uses heavy purple for 50/500/600 enforce high-contrast white
* foreground on solid brand fills (including opacity modifiers).
*/
:is(
.bg-brand-50,
.bg-brand-500,
.bg-brand-600,
[class*="bg-brand-500/"],
[class*="bg-brand-600/"]
) {
@apply text-white;
}
:is(
.bg-brand-50,
.bg-brand-500,
.bg-brand-600,
[class*="bg-brand-500/"],
[class*="bg-brand-600/"]
)
:is(.text-brand-500, .text-brand-600, .text-brand-700, .text-brand-800) {
@apply text-white;
}
:is(
.bg-brand-50,
.bg-brand-500,
.bg-brand-600,
[class*="bg-brand-500/"],
[class*="bg-brand-600/"]
)
svg:not([class*="text-"]) {
@apply text-white;
}
.hover\:bg-brand-50:hover,
.hover\:bg-brand-500:hover,
.hover\:bg-brand-600:hover {
@apply text-white;
}
.hover\:bg-brand-50:hover svg,
.hover\:bg-brand-500:hover svg,
.hover\:bg-brand-600:hover svg {
@apply text-white;
}
/* Map legacy light-only surfaces to theme tokens in dark mode */
html.dark .bg-white {
background-color: var(--gs-50);
}
}

143
src/lib/activityLogActor.ts Normal file
View File

@ -0,0 +1,143 @@
import { getTeamMemberById } from "../api/team.api"
import { getUserById } from "../api/users.api"
import { TEAM_ROLE_OPTIONS, formatTeamRoleLabel } from "./teamRoles"
import type { TeamMember } from "../types/team.types"
import type { UserProfileData } from "../types/user.types"
const TEAM_ROLE_VALUES = new Set(
TEAM_ROLE_OPTIONS.map((o) => o.value.toUpperCase()),
)
const APP_USER_ROLES = new Set(["STUDENT", "LEARNER", "USER", "SUBSCRIBER"])
export type ActorProfileKind = "team" | "user"
export type ActorProfile =
| {
kind: "team"
id: number
name: string
email: string
roleLabel: string
status: string
emailVerified: boolean
createdAt: string
}
| {
kind: "user"
id: number
name: string
email: string
roleLabel: string
status: string
emailVerified: boolean
country: string
region: string
lastLogin: string | null
subscriptionStatus: string
createdAt: string
}
function normalizeRole(role: string): string {
return role.trim().toUpperCase().replace(/[\s-]+/g, "_")
}
/** Choose API from activity log `actor_role` (team_role vs learner role). */
export function resolveActorKind(actorRole: string | null | undefined): ActorProfileKind | null {
if (!actorRole?.trim()) return null
const upper = normalizeRole(actorRole)
if (TEAM_ROLE_VALUES.has(upper)) return "team"
if (APP_USER_ROLES.has(upper)) return "user"
return null
}
function teamMemberToProfile(member: TeamMember): ActorProfile {
return {
kind: "team",
id: member.id,
name: [member.first_name, member.last_name].filter(Boolean).join(" ") || "—",
email: member.email || "—",
roleLabel: formatTeamRoleLabel(member.team_role),
status: member.status || "—",
emailVerified: Boolean(member.email_verified),
createdAt: member.created_at,
}
}
function userToProfile(user: UserProfileData): ActorProfile {
return {
kind: "user",
id: user.id,
name: [user.first_name, user.last_name].filter(Boolean).join(" ") || "—",
email: user.email || "—",
roleLabel: user.role
? user.role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
: "—",
status: user.status || "—",
emailVerified: Boolean(user.email_verified),
country: user.country || "—",
region: user.region || "—",
lastLogin: user.last_login,
subscriptionStatus: user.subscription_status?.trim() || "—",
createdAt: user.created_at,
}
}
const profileCache = new Map<string, ActorProfile | "error">()
function cacheKey(actorId: number, kind: ActorProfileKind): string {
return `${kind}:${actorId}`
}
async function fetchTeamProfile(actorId: number): Promise<ActorProfile> {
const res = await getTeamMemberById(actorId)
return teamMemberToProfile(res.data.data)
}
async function fetchUserProfile(actorId: number): Promise<ActorProfile> {
const res = await getUserById(actorId)
return userToProfile(res.data.data)
}
export async function fetchActorProfile(
actorId: number,
actorRole: string | null | undefined,
): Promise<ActorProfile> {
const kind = resolveActorKind(actorRole)
const load = async (target: ActorProfileKind): Promise<ActorProfile> => {
const key = cacheKey(actorId, target)
const cached = profileCache.get(key)
if (cached && cached !== "error") return cached
if (cached === "error") throw new Error("Actor not found")
try {
const profile =
target === "team" ? await fetchTeamProfile(actorId) : await fetchUserProfile(actorId)
profileCache.set(key, profile)
return profile
} catch (e) {
profileCache.set(key, "error")
throw e
}
}
if (kind === "team") return load("team")
if (kind === "user") return load("user")
try {
return await load("team")
} catch {
return load("user")
}
}
export function formatActorDate(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}

79
src/lib/appVersions.ts Normal file
View File

@ -0,0 +1,79 @@
import type {
AppPlatform,
AppUpdateType,
AppVersion,
AppVersionStatus,
} from "../types/app-version.types"
export const APP_PLATFORMS: { value: AppPlatform; label: string }[] = [
{ value: "ANDROID", label: "Android" },
{ value: "IOS", label: "iOS" },
]
export const APP_UPDATE_TYPES: { value: AppUpdateType; label: string; description: string }[] = [
{
value: "FORCE",
label: "Force update",
description: "Users must update before continuing",
},
{
value: "SOFT",
label: "Soft update",
description: "Recommended update; users can dismiss",
},
{
value: "OPTIONAL",
label: "Optional",
description: "Informational prompt only",
},
]
export const APP_VERSION_STATUSES: { value: AppVersionStatus; label: string }[] = [
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
{ value: "DRAFT", label: "Draft" },
]
export const DEFAULT_STORE_URLS: Record<string, string> = {
ANDROID: "https://play.google.com/store/apps/details?id=com.yimaru.app",
IOS: "https://apps.apple.com/app/id000000000",
}
export function formatAppPlatform(platform: string): string {
const match = APP_PLATFORMS.find((p) => p.value === platform)
if (match) return match.label
return platform.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatUpdateType(updateType: string): string {
const match = APP_UPDATE_TYPES.find((t) => t.value === updateType)
if (match) return match.label
return updateType.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatVersionStatus(status: string): string {
const match = APP_VERSION_STATUSES.find((s) => s.value === status)
if (match) return match.label
return status.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatAppVersionCreatedAt(raw: string): string {
if (!raw) return "—"
const normalized = raw.replace(" +0000 UTC", "Z").replace(/^(\d{4}-\d{2}-\d{2}) /, "$1T")
const d = new Date(normalized)
if (Number.isNaN(d.getTime())) {
const datePart = raw.split(" ")[0]
return datePart || raw
}
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function versionLabel(version: Pick<AppVersion, "version_name" | "version_code">): string {
return `v${version.version_name} (${version.version_code})`
}

9
src/lib/auth.ts Normal file
View File

@ -0,0 +1,9 @@
/** Clear session tokens and redirect to login (full page navigation). */
export function logoutToLogin(options?: { passwordChanged?: boolean }) {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
localStorage.removeItem("member_id")
localStorage.removeItem("role")
const search = options?.passwordChanged ? "?password_changed=1" : ""
window.location.href = `/login${search}`
}

View File

@ -0,0 +1,57 @@
export type DynamicTableValue = {
columns: string[]
rows: string[][]
}
const DEFAULT_TABLE: DynamicTableValue = {
columns: ["Column 1", "Column 2"],
rows: [["", ""]],
}
function isRecord(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v)
}
function normalizeRows(columns: string[], rows: unknown): string[][] {
const colCount = Math.max(1, columns.length)
if (!Array.isArray(rows)) return [Array(colCount).fill("")]
return rows.map((row) => {
if (!Array.isArray(row)) return Array(colCount).fill("")
const cells = row.map((c) => String(c ?? ""))
while (cells.length < colCount) cells.push("")
return cells.slice(0, colCount)
})
}
export function parseTableSlotValue(raw: string | undefined): DynamicTableValue {
const t = (raw ?? "").trim()
if (!t) return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
try {
const parsed = JSON.parse(t) as unknown
if (isRecord(parsed) && Array.isArray(parsed.columns)) {
const columns = parsed.columns.map((c) => String(c ?? "").trim() || "Column")
if (columns.length === 0) columns.push("Column 1")
const rows = normalizeRows(columns, parsed.rows)
return { columns, rows: rows.length > 0 ? rows : [Array(columns.length).fill("")] }
}
} catch {
/* fall through */
}
return { ...DEFAULT_TABLE, columns: [...DEFAULT_TABLE.columns], rows: DEFAULT_TABLE.rows.map((r) => [...r]) }
}
export function serializeTableSlotValue(table: DynamicTableValue): string {
const columns = table.columns.map((c) => c.trim() || "Column")
const rows = normalizeRows(columns, table.rows).map((row) =>
row.map((cell) => cell.trim()),
)
return JSON.stringify({ columns, rows })
}
export function createEmptyTable(columnCount = 2, rowCount = 1): DynamicTableValue {
const columns = Array.from({ length: Math.max(1, columnCount) }, (_, i) => `Column ${i + 1}`)
const rows = Array.from({ length: Math.max(1, rowCount) }, () => Array(columns.length).fill(""))
return { columns, rows }
}

View File

@ -1,7 +1,16 @@
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
function defaultValueForSchemaSlot(kind: string): string {
const u = kind.trim().toUpperCase()
if (u === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
return ""
}
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
}
@ -10,11 +19,55 @@ export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition,
): Record<string, string> {
const o: Record<string, string> = {}
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
for (const r of def.response_schema) o[`response:${r.id}`] = ""
for (const r of def.stimulus_schema) {
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
}
return o
}
const PROMPT_STIMULUS_KINDS = new Set(["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"])
/** First stimulus slot used for a plain-text prompt shortcut in the practice UI. */
export function primaryPromptStimulusRow(
def: QuestionTypeDefinition,
): { id: string; kind: string } | null {
for (const kind of ["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"] as const) {
const row = def.stimulus_schema.find((r) => r.kind === kind)
if (row) return { id: row.id, kind: row.kind }
}
return def.stimulus_schema[0] ? { id: def.stimulus_schema[0].id, kind: def.stimulus_schema[0].kind } : null
}
export function mergePromptIntoDynamicFieldValues(
def: QuestionTypeDefinition,
questionText: string,
fieldValues: Record<string, string>,
): Record<string, string> {
const merged = { ...fieldValues }
const prompt = questionText.trim()
if (!prompt) return merged
const slot = primaryPromptStimulusRow(def)
if (!slot) return merged
const key = `stimulus:${slot.id}`
if (!merged[key]?.trim()) merged[key] = prompt
return merged
}
export function dynamicPromptFromFieldValues(
def: QuestionTypeDefinition,
fieldValues: Record<string, string>,
): string {
for (const row of def.stimulus_schema) {
if (!PROMPT_STIMULUS_KINDS.has(row.kind)) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (v) return v
}
return ""
}
/**
* System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
@ -41,6 +94,24 @@ export interface LearnEnglishDefinitionQuestionInput {
sampleAnswerVoiceUrl?: string
}
export function questionRowHasContent(
q: LearnEnglishDefinitionQuestionInput,
def: QuestionTypeDefinition,
): boolean {
if (!definitionUsesDynamicPayload(def)) {
return Boolean(q.questionText.trim())
}
if (q.questionText.trim()) return true
const fv = q.dynamicFieldValues ?? {}
for (const row of def.stimulus_schema) {
if (fv[`stimulus:${row.id}`]?.trim()) return true
}
for (const row of def.response_schema) {
if (fv[`response:${row.id}`]?.trim()) return true
}
return false
}
export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
@ -51,13 +122,17 @@ export function buildCreateQuestionFromDefinition(
const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues: q.dynamicFieldValues ?? {},
fieldValues,
})
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
@ -118,9 +193,7 @@ export function buildCreateQuestionFromDefinition(
}
}
// No schema and no legacy key mapping: still create as DYNAMIC with empty payload + definition id
return {
question_text,
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
@ -136,24 +209,36 @@ export function validateDefinitionQuestion(
index1Based: number,
): string | null {
const n = index1Based
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const hasPrompt =
Boolean(q.questionText.trim()) || Boolean(dynamicPromptFromFieldValues(def, fieldValues))
const promptRow = def.stimulus_schema.find((r) => PROMPT_STIMULUS_KINDS.has(r.kind) && r.required)
if (promptRow && !hasPrompt) {
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
}
for (const row of def.stimulus_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim()
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
}
for (const row of def.response_schema) {
if (!row.required) continue
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim()
const v = fieldValues[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
return null
}
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())

View File

@ -9,6 +9,7 @@ import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
questionRowHasContent,
validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
@ -30,9 +31,12 @@ export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[],
): string | null {
const filled = questions.filter((q) => q.questionText.trim())
if (filled.length === 0) return "Add at least one question with prompt text."
const byId = new Map(definitions.map((d) => [d.id, d]))
const filled = questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
if (filled.length === 0) return "Add at least one question with content."
for (let i = 0; i < filled.length; i++) {
const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
@ -100,7 +104,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
)
}
const toCreate = opts.questions.filter((q) => q.questionText.trim())
const toCreate = opts.questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
let displayOrder = 0
for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)

View File

@ -0,0 +1,101 @@
import {
Bell,
Info,
AlertCircle,
CheckCircle2,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
} from "lucide-react"
export const NOTIFICATION_TYPE_CONFIG: Record<
string,
{ icon: React.ElementType; color: string; bg: string }
> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
export const DEFAULT_NOTIFICATION_TYPE_CONFIG = {
icon: Bell,
color: "text-grayScale-500",
bg: "bg-grayScale-100",
}
export function getNotificationLevelBadge(level: string) {
switch (level) {
case "error":
case "critical":
return "destructive" as const
case "warning":
return "warning" as const
case "success":
return "success" as const
case "info":
default:
return "info" as const
}
}
export function formatNotificationTimestamp(ts: string) {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return "—"
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
export function formatNotificationTypeLabel(type: string) {
return type
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
export function formatNotificationDateTime(ts: string) {
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return "—"
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function isMeaningfulExpiry(expires: string) {
if (!expires) return false
const date = new Date(expires)
if (Number.isNaN(date.getTime())) return false
return date.getFullYear() > 1
}

51
src/lib/payments.ts Normal file
View File

@ -0,0 +1,51 @@
import { formatPlanCategory } from "./subscriptionPlans"
import type { Payment } from "../types/payment.types"
export function formatPaymentAmount(payment: Pick<Payment, "amount" | "currency">): string {
const amount = Number(payment.amount)
const formatted = Number.isFinite(amount)
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
: String(payment.amount)
return `${formatted} ${payment.currency || "ETB"}`
}
export function formatPaymentDate(iso: string | null | undefined): string {
if (!iso) return "—"
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function formatPaymentStatus(status: string): string {
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatPaymentMethod(method: string): string {
if (!method) return "—"
return method.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function paymentCustomerName(payment: Payment): string {
const name = [payment.user_first_name, payment.user_last_name].filter(Boolean).join(" ")
return name || payment.user_email || `User #${payment.user_id}`
}
export function formatPaymentPlanCategory(category: string): string {
return formatPlanCategory(category)
}
export function paymentStatusBadgeVariant(
status: string,
): "success" | "warning" | "destructive" | "secondary" | "info" {
const s = status.toUpperCase()
if (s === "SUCCESS" || s === "COMPLETED" || s === "PAID") return "success"
if (s === "PENDING" || s === "PROCESSING") return "warning"
if (s === "FAILED" || s === "CANCELLED" || s === "EXPIRED") return "destructive"
return "secondary"
}

View File

@ -0,0 +1,41 @@
/** Author-facing default labels for dynamic schema slots. */
const KIND_DEFAULT_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question prompt",
PREP_TIME: "Preparation time (seconds)",
INSTRUCTION: "Instructions",
AUDIO_PROMPT: "Audio",
TEXT_PASSAGE: "Reading passage",
IMAGE: "Image",
MATCHING_INPUTS: "Matching inputs",
SELECT_MISSING_WORDS: "Select missing words",
TABLE: "Reference table",
PDF_ATTACHMENT: "PDF document",
AUDIO_RESPONSE: "Audio response",
TEXT_INPUT: "Text input",
SHORT_ANSWER: "Short answer",
MULTIPLE_CHOICE: "Multiple choice",
OPTION: "Answer choices",
ANSWER_TIMER: "Time limit (seconds)",
PDF_UPLOAD: "PDF upload",
MATCHING_ANSWER: "Matching answer",
LABEL_SELECTION: "Label selection",
SEQUENCE_ORDER: "Sequence order",
}
export function humanizeKind(kind: string): string {
return kind
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function defaultLabelForKind(kind: string): string {
const k = kind.trim()
return KIND_DEFAULT_LABELS[k] ?? humanizeKind(k)
}
export function slotLabel(schema: { label?: string | null; kind: string }): string {
const trimmed = schema.label?.trim()
if (trimmed) return trimmed
return defaultLabelForKind(schema.kind)
}

View File

@ -0,0 +1,61 @@
import type {
SubscriptionPlan,
SubscriptionPlanCategory,
SubscriptionPlanDurationUnit,
} from "../types/subscription.types"
export const SUBSCRIPTION_PLAN_CATEGORIES: {
value: SubscriptionPlanCategory
label: string
}[] = [
{ value: "LEARN_ENGLISH", label: "Learn English" },
{ value: "EXAM_PREP", label: "Exam prep" },
{ value: "SKILLS", label: "Skills" },
]
export const SUBSCRIPTION_DURATION_UNITS: {
value: SubscriptionPlanDurationUnit
label: string
}[] = [
{ value: "DAY", label: "Day(s)" },
{ value: "WEEK", label: "Week(s)" },
{ value: "MONTH", label: "Month(s)" },
{ value: "YEAR", label: "Year(s)" },
]
export const SUBSCRIPTION_CURRENCIES = ["ETB", "USD"] as const
export function formatPlanDuration(plan: Pick<SubscriptionPlan, "duration_value" | "duration_unit">): string {
const v = plan.duration_value
const u = String(plan.duration_unit).toUpperCase()
const word =
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
return `${v} ${v === 1 ? word : `${word}s`}`
}
return `${v} ${word}`
}
export function formatPlanPrice(plan: Pick<SubscriptionPlan, "price" | "currency">): string {
const amount = Number(plan.price)
const formatted = Number.isFinite(amount)
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
: String(plan.price)
return `${formatted} ${plan.currency}`
}
export function formatPlanCategory(category: string): string {
const match = SUBSCRIPTION_PLAN_CATEGORIES.find((c) => c.value === category)
if (match) return match.label
return category.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatPlanCreatedAt(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
}

View File

@ -0,0 +1,6 @@
/** Standard page-size choices for admin data tables. */
export const TABLE_PAGE_SIZE_OPTIONS = [5, 10, 30, 50, 100] as const
export type TablePageSize = (typeof TABLE_PAGE_SIZE_OPTIONS)[number]
export const DEFAULT_TABLE_PAGE_SIZE: TablePageSize = 10

64
src/lib/theme.ts Normal file
View File

@ -0,0 +1,64 @@
export type ThemeMode = "light" | "dark" | "system"
export type ResolvedTheme = "light" | "dark"
export const THEME_STORAGE_KEY = "yimaru-admin-theme"
const MEDIA_QUERY = "(prefers-color-scheme: dark)"
export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined") return "light"
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light"
}
/** Resolved appearance: light mode always forces light; dark forces dark; system follows OS. */
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "light") return "light"
if (mode === "dark") return "dark"
return getSystemTheme()
}
export function getStoredTheme(): ThemeMode {
if (typeof window === "undefined") return "light"
const value = localStorage.getItem(THEME_STORAGE_KEY)
if (value === "light" || value === "dark" || value === "system") return value
return "light"
}
function syncMetaThemeColor(resolved: ResolvedTheme) {
if (typeof document === "undefined") return
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null
if (!meta) {
meta = document.createElement("meta")
meta.name = "theme-color"
document.head.appendChild(meta)
}
meta.content = resolved === "dark" ? "#12121a" : "#f5f5f5"
}
export function applyTheme(mode: ThemeMode): ResolvedTheme {
const resolved = resolveTheme(mode)
const root = document.documentElement
root.classList.remove("dark")
if (resolved === "dark") {
root.classList.add("dark")
}
root.dataset.theme = resolved
root.dataset.themePreference = mode
root.style.colorScheme = resolved
syncMetaThemeColor(resolved)
return resolved
}
export function watchSystemTheme(onChange: (resolved: ResolvedTheme) => void): () => void {
if (typeof window === "undefined") return () => undefined
const media = window.matchMedia(MEDIA_QUERY)
const handler = () => onChange(getSystemTheme())
media.addEventListener("change", handler)
return () => media.removeEventListener("change", handler)
}

View File

@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './contexts/ThemeContext.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<ThemeProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</StrictMode>,
)

View File

@ -47,6 +47,7 @@ import {
getVideoLessonsSummary,
} from "../lib/analytics"
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
import { formatPlanDuration } from "../lib/subscriptionPlans"
import type { SubscriptionPlan } from "../types/subscription.types"
import type { Rating } from "../types/course.types"
@ -59,17 +60,6 @@ function formatDate(dateStr: string) {
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
function formatPlanDuration(plan: SubscriptionPlan): string {
const v = plan.duration_value
const u = plan.duration_unit.toUpperCase()
const word =
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
return `${v} ${v === 1 ? word : `${word}s`}`
}
return `${v} ${word}`
}
export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
@ -120,7 +110,7 @@ export function DashboardPage() {
setSubscriptionPlansLoading(true)
try {
const res = await getSubscriptionPlans()
setSubscriptionPlans(res.data.data)
setSubscriptionPlans(res.data)
} catch (err) {
console.error(err)
setSubscriptionPlans([])

View File

@ -1,11 +1,8 @@
import React, { useEffect, useState } from "react";
import {
Bell,
Eye,
EyeOff,
Globe,
KeyRound,
Languages,
Lock,
Moon,
Palette,
@ -14,8 +11,7 @@ import {
Sun,
User,
CreditCard,
AlertTriangle,
X,
Smartphone,
} from "lucide-react";
import {
Card,
@ -26,228 +22,33 @@ import {
import { Input } from "../components/ui/input";
import { Button } from "../components/ui/button";
import { Select } from "../components/ui/select";
import { Separator } from "../components/ui/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { cn } from "../lib/utils";
import { SpinnerIcon } from "../components/ui/spinner-icon";
import { changeTeamMemberPassword } from "../api/team.api";
import { logoutToLogin } from "../lib/auth";
import { getMyProfile, updateProfile } from "../api/users.api";
import type { UserProfileData } from "../types/user.types";
import { toast } from "sonner";
import { AppVersionsTab } from "./settings/AppVersionsTab";
import { SubscriptionPlansTab } from "./settings/SubscriptionPlansTab";
import { ThemeModePreview } from "./settings/components/ThemeModePreview";
import { useTheme } from "../contexts/ThemeContext";
type SettingsTab =
| "subscription"
| "app-versions"
| "profile"
| "security"
| "notifications"
| "appearance";
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
{ id: "subscription", label: "Subscription", icon: CreditCard },
{ id: "subscription", label: "Subscription packages", icon: CreditCard },
{ id: "app-versions", label: "App versions", icon: Smartphone },
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "appearance", label: "Appearance", icon: Palette },
];
function Toggle({
enabled,
onToggle,
}: {
enabled: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onToggle}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none",
enabled ? "bg-brand-500" : "bg-grayScale-200",
)}
>
<span
className={cn(
"pointer-events-none inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
)}
/>
</button>
);
}
function SettingRow({
icon: Icon,
title,
description,
children,
}: {
icon: any;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-4 rounded-[6px] px-3 py-4 transition-colors hover:bg-grayScale-100/50">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] bg-grayScale-100 text-grayScale-400">
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-800">{title}</p>
<p className="mt-0.5 text-xs text-grayScale-500">{description}</p>
</div>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
// --- Subscription Tab ---
function SubscriptionTab() {
const [subs, setSubs] = useState([
{
id: "auto_renew",
name: "Auto-renewal",
desc: "Automatically renew your subscription when it expires",
enabled: true,
},
{
id: "marketing_emails",
name: "Marketing Emails",
desc: "Receive updates about new features and promotions",
enabled: true,
},
{
id: "priority_support",
name: "Priority Support",
desc: "Access 24/7 priority customer support",
enabled: true,
},
]);
const [pendingToggle, setPendingToggle] = useState<string | null>(null);
const [showWarning, setShowWarning] = useState(false);
const handleToggle = (id: string) => {
const item = subs.find((s) => s.id === id);
if (item?.enabled) {
setPendingToggle(id);
setShowWarning(true);
} else {
setSubs((prev) =>
prev.map((s) => (s.id === id ? { ...s, enabled: true } : s)),
);
}
};
const confirmToggleOff = () => {
if (pendingToggle) {
setSubs((prev) =>
prev.map((s) =>
s.id === pendingToggle ? { ...s, enabled: false } : s,
),
);
setShowWarning(false);
setPendingToggle(null);
}
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Subscription Features
</CardTitle>
<p className="text-[11px] text-grayScale-500">
Customize your subscription experience and management preferences
</p>
</CardHeader>
<CardContent className="space-y-0 p-0">
{subs.map((sub, idx) => (
<React.Fragment key={sub.id}>
<div
className={cn(
"px-2",
idx < subs.length - 1 && "border-b border-grayScale-50",
)}
>
<SettingRow
icon={CreditCard}
title={sub.name}
description={sub.desc}
>
<Toggle
enabled={sub.enabled}
onToggle={() => handleToggle(sub.id)}
/>
</SettingRow>
</div>
</React.Fragment>
))}
</CardContent>
</Card>
<Dialog open={showWarning} onOpenChange={setShowWarning}>
<DialogContent className="max-w-md p-0 overflow-hidden border border-grayScale-100 rounded-[12px] shadow-2xl">
<div className="relative p-8">
<div className="flex items-start gap-5 mb-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-500 border border-red-100">
<AlertTriangle className="h-7 w-7" />
</div>
<div className="pt-1">
<h3 className="text-xl font-bold text-grayScale-900 tracking-tight">
Are you absolutely sure?
</h3>
<p className="text-sm text-grayScale-500 mt-1">
Disabling this feature might limit your experience.
</p>
</div>
</div>
<div className="bg-grayScale-50/80 border border-grayScale-100 p-5 rounded-[8px] mb-8">
<p className="text-sm text-grayScale-600 leading-relaxed font-medium">
By turning this off, you will no longer receive the benefits
associated with this feature. Some changes might take up to 24
hours to reflect.
</p>
</div>
<div className="flex flex-col gap-3">
<Button
variant="destructive"
onClick={confirmToggleOff}
className="w-full rounded-[8px] py-6 text-sm font-bold bg-red-500 hover:bg-red-600 text-white border-none shadow-sm transition-all active:scale-[0.98]"
>
Yes, Disable Feature
</Button>
<Button
variant="outline"
onClick={() => setShowWarning(false)}
className="w-full rounded-[8px] py-6 text-sm font-bold border-grayScale-200 text-grayScale-600 hover:bg-grayScale-50 transition-all active:scale-[0.98]"
>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// --- Other Tabs (Existing, but with sidebar layout updates) ---
function ProfileTab({ profile }: { profile: UserProfileData }) {
const [firstName, setFirstName] = useState(profile.first_name);
const [lastName, setLastName] = useState(profile.last_name);
@ -363,17 +164,46 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
);
}
function SecurityTab() {
function SecurityTab({ memberId }: { memberId: number }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [saving, setSaving] = useState(false);
const handleChangePassword = async () => {
if (!currentPassword.trim()) {
toast.error("Enter your current password.");
return;
}
if (newPassword.length < 8) {
toast.error("New password must be at least 8 characters.");
return;
}
if (newPassword !== confirmPassword) {
toast.error("New password and confirmation do not match.");
return;
}
if (currentPassword === newPassword) {
toast.error("New password must be different from your current password.");
return;
}
setSaving(true);
try {
await new Promise((r) => setTimeout(r, 600));
toast.success("Password updated successfully");
await changeTeamMemberPassword(memberId, {
current_password: currentPassword,
new_password: newPassword,
});
logoutToLogin({ passwordChanged: true });
return;
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to update password.";
toast.error(msg);
} finally {
setSaving(false);
}
@ -397,7 +227,11 @@ function SecurityTab() {
<Input
type={showCurrent ? "text" : "password"}
placeholder="Enter current password"
className="rounded-[6px]"
className="rounded-[6px] pr-10"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={saving}
/>
<button
type="button"
@ -421,7 +255,11 @@ function SecurityTab() {
<Input
type={showNew ? "text" : "password"}
placeholder="Enter new password"
className="rounded-[6px]"
className="rounded-[6px] pr-10"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={saving}
/>
<button
type="button"
@ -444,7 +282,11 @@ function SecurityTab() {
<Input
type={showConfirm ? "text" : "password"}
placeholder="Confirm new password"
className="rounded-[6px]"
className="rounded-[6px] pr-10"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={saving}
/>
<button
type="button"
@ -532,52 +374,101 @@ function NotificationsTab() {
}
function AppearanceTab() {
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
const options = [
{
id: "light" as const,
label: "Light",
description: "Always bright UI",
icon: Sun,
preview: "light" as const,
},
{
id: "dark" as const,
label: "Dark",
description: "Always dark UI",
icon: Moon,
preview: "dark" as const,
},
{
id: "system" as const,
label: "System",
description: `Follows device (${systemTheme})`,
icon: Globe,
preview: "system" as const,
},
];
return (
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="pb-3 border-b border-grayScale-50">
<CardTitle className="text-sm font-bold text-grayScale-900">
Theme
</CardTitle>
</CardHeader>
<CardContent className="pb-6">
<div className="grid gap-3 sm:grid-cols-3">
{(
[
{ id: "light", label: "Light", icon: Sun },
{ id: "dark", label: "Dark", icon: Moon },
{ id: "system", label: "System", icon: Globe },
] as const
).map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
theme === id
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
)}
>
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-[6px]",
theme === id
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-400",
)}
>
<Icon className="h-5 w-5" />
</div>
<span className="text-sm font-medium">{label}</span>
</button>
))}
</div>
</CardContent>
</Card>
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
<Card className="overflow-hidden rounded-[6px] border border-grayScale-200">
<div className="h-1 w-full bg-brand-400" />
<CardHeader className="border-b border-grayScale-200 pb-3">
<CardTitle className="text-sm font-bold text-grayScale-600">Theme</CardTitle>
<p className="text-xs text-grayScale-400">
Active appearance:{" "}
<span className="font-semibold capitalize text-grayScale-600">{resolvedTheme}</span>
{theme === "system" ? " (from your device setting)" : null}
{theme === "light" ? " (fixed — not tied to device)" : null}
</p>
</CardHeader>
<CardContent className="pb-6 pt-4">
<div className="grid gap-3 sm:grid-cols-3">
{options.map(({ id, label, description, icon: Icon, preview }) => {
const selected = theme === id;
return (
<button
key={id}
type="button"
onClick={() => setTheme(id)}
className={cn(
"flex flex-col items-stretch gap-3 rounded-[8px] border-2 p-3 text-left transition-all",
selected
? "border-brand-500 bg-brand-500/10 shadow-sm ring-1 ring-brand-500/30"
: "border-grayScale-200 bg-grayScale-50 hover:border-grayScale-300 hover:bg-grayScale-100",
)}
>
<ThemeModePreview
variant={preview}
systemResolved={systemTheme}
/>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px]",
selected
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-500",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p
className={cn(
"text-sm font-semibold",
selected ? "text-grayScale-600" : "text-grayScale-500",
)}
>
{label}
</p>
<p className="text-[11px] text-grayScale-400">{description}</p>
</div>
</div>
</button>
);
})}
</div>
<p className="mt-4 rounded-[6px] border border-dashed border-grayScale-200 bg-grayScale-100 px-3 py-2 text-[11px] leading-relaxed text-grayScale-500">
<strong className="font-semibold text-grayScale-600">Light vs System:</strong> Light
always stays bright. System copies your Windows/macOS theme if your device is in
light mode, System will match Light; switch your device to dark to see System use the
dark admin theme.
</p>
</CardContent>
</Card>
</div>
);
}
@ -644,10 +535,36 @@ export function SettingsPage() {
</p>
</div>
<div className="flex flex-col gap-8">
{/* Content Area */}
<main className="min-h-[400px]">
{activeTab === "subscription" && <SubscriptionTab />}
<div className="flex min-w-0 flex-col gap-8 lg:flex-row lg:items-start">
<nav className="flex shrink-0 flex-row gap-1 overflow-x-auto rounded-[8px] border border-grayScale-100 bg-white p-1 lg:w-56 lg:flex-col">
{tabs.map((tab) => {
const Icon = tab.icon;
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2.5 whitespace-nowrap rounded-[6px] px-3 py-2.5 text-left text-sm font-medium transition-colors",
active
? "bg-brand-50 text-brand-600"
: "text-grayScale-600 hover:bg-grayScale-50",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{tab.label}
</button>
);
})}
</nav>
<main className="min-h-[400px] min-w-0 w-full flex-1">
{activeTab === "subscription" && <SubscriptionPlansTab />}
{activeTab === "app-versions" && <AppVersionsTab />}
{activeTab === "profile" && <ProfileTab profile={profile} />}
{activeTab === "security" && <SecurityTab memberId={profile.id} />}
{activeTab === "appearance" && <AppearanceTab />}
</main>
</div>
</div>

View File

@ -665,6 +665,11 @@ export function AnalyticsPage() {
data={users.by_age_group ?? []}
total={users.total_users}
/>
<BreakdownList
title="Country"
data={users.by_country ?? []}
total={users.total_users}
/>
<BreakdownList
title="Region"
data={users.by_region ?? []}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link, Navigate, useNavigate } from "react-router-dom";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { Eye, EyeOff } from "lucide-react";
import { BrandLogo } from "../../components/brand/BrandLogo";
@ -65,9 +65,18 @@ function GoogleIcon({ className }: { className?: string }) {
export function LoginPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const token = localStorage.getItem("access_token");
useEffect(() => {
if (searchParams.get("password_changed") !== "1") return;
toast.success("Password updated", {
description: "Sign in with your new password.",
});
setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

View File

@ -373,30 +373,34 @@ export function AddNewPracticePage() {
})
: undefined;
const qRes = await createQuestion({
question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
sample_answer_voice_prompt:
q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text:
q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
short_answers:
q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
const qRes = await createQuestion(
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type: "DYNAMIC",
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
}
: {}),
});
: {
question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
},
);
const questionId = qRes.data?.data?.id;
if (questionId) {

View File

@ -150,7 +150,7 @@ export function AddPracticeFlow() {
setDefinitionsLoading(true);
setDefinitionsError(null);
try {
const list = await getQuestionTypeDefinitions({
const { definitions: list } = await getQuestionTypeDefinitions({
include_system: true,
status: "ACTIVE",
});
@ -216,9 +216,7 @@ export function AddPracticeFlow() {
return;
}
const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions
.filter((q) => String(q.text ?? "").trim())
.map((q) => ({
const mappedQuestions = formData.questions.map((q) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
@ -372,6 +370,7 @@ export function AddPracticeFlow() {
onCancel={() => navigate(backPath)}
isLessonPractice={isLessonPractice}
lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary}
/>
);
case 2:
@ -515,9 +514,7 @@ export function AddPracticeFlow() {
</Button>
</div>
<p className="text-grayScale-400 text-base">
Create a practice: question types from{" "}
<code className="text-xs">GET /questions/type-definitions</code>, then
question set and POST /practices.
Create a practice with story details, a persona, and questions from your question type library.
</p>
{lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">

View File

@ -141,8 +141,8 @@ export function AddQuestionPage() {
let cancelled = false
;(async () => {
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
}
@ -268,7 +268,7 @@ export function AddQuestionPage() {
return
}
} catch {
toast.error("Invalid JSON", { description: "Fix dynamic_payload JSON before saving." })
toast.error("Invalid JSON", { description: "Fix the dynamic content JSON before saving." })
return
}
}
@ -419,7 +419,7 @@ export function AddQuestionPage() {
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
dynamic_payload (JSON) <span className="text-red-500">*</span>
Dynamic content (JSON) <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.dynamicPayloadJson}

View File

@ -28,6 +28,7 @@ import {
import { Textarea } from "../../components/ui/textarea"
import { toast } from "sonner"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
type CourseWithCategory = Course & { category_name: string }
@ -422,7 +423,7 @@ export function AllCoursesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -1,11 +1,10 @@
import { Outlet } from "react-router-dom";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
export function ContentManagementLayout() {
return (
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="mb-8">
<div className="flex items-center gap-3 mb-8">
<div className="mb-8 flex items-center gap-3">
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
<div>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
@ -16,8 +15,6 @@ export function ContentManagementLayout() {
</p>
</div>
</div>
<ContentHierarchyList />
</div>
<Outlet />

View File

@ -427,11 +427,7 @@ export function CourseDetailPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit module</DialogTitle>
<DialogDescription>
Update name, sort order, and icon (upload or URL). Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /modules/:id
</code>
.
Update name, sort order, and icon (upload or URL).
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">

View File

@ -39,6 +39,7 @@ import {
} from "../../api/courses.api"
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
export function CoursesPage() {
const { categoryId } = useParams<{ categoryId: string }>()
@ -513,7 +514,7 @@ export function CoursesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -20,6 +20,7 @@ import type {
} from "../../types/questionTypeDefinition.types"
import {
buildCreatePayload,
buildValidateKindsPayload,
validateDefinitionBasic,
validateDefinitionKinds,
validateDefinitionSchemas,
@ -29,6 +30,7 @@ import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/Ques
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
import { defaultLabelForKind } from "../../lib/schemaSlotLabel"
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
key: "",
@ -45,7 +47,7 @@ function seedSchemaFromKinds(kinds: string[]) {
return kinds.map((k, i) => ({
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
kind: k,
label: k.replace(/_/g, " "),
label: defaultLabelForKind(k),
required: true as boolean,
}))
}
@ -58,8 +60,14 @@ function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionC
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
response_component_kinds: [...(def.response_component_kinds ?? [])],
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({ ...r })),
response_schema: (def.response_schema ?? []).map((r) => ({ ...r })),
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
response_schema: (def.response_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
}
}
@ -75,9 +83,9 @@ export function CreateQuestionTypeFlow() {
const [currentStep, setCurrentStep] = useState(1)
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
const [versionName, setVersionName] = useState("Test 1")
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
const [definitionReady, setDefinitionReady] = useState(!isEdit)
const [isSystemDefinition, setIsSystemDefinition] = useState(false)
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
stimulus_component_kinds: [],
@ -134,7 +142,7 @@ export function CreateQuestionTypeFlow() {
return
}
setDraft(definitionToDraft(def))
setVersionName("Test 1")
setIsSystemDefinition(Boolean(def.is_system))
setCurrentStep(1)
setStepErrors({})
} catch (e) {
@ -155,7 +163,6 @@ export function CreateQuestionTypeFlow() {
useEffect(() => {
if (!isEdit) {
setDraft(initialDraft())
setVersionName("Test 1")
setCurrentStep(1)
setStepErrors({})
setDefinitionReady(true)
@ -179,15 +186,10 @@ export function CreateQuestionTypeFlow() {
}
const handleNextFromStep2 = () => {
const versionErr: FieldErrorMap = {}
if (!versionName.trim()) {
versionErr.version_name = "Version name is required."
}
const eKinds = validateDefinitionKinds(draft, componentCatalog)
const mergedKinds = { ...versionErr, ...eKinds }
setStepErrors(mergedKinds)
if (Object.keys(mergedKinds).length) {
toast.error("Complete version name and component selections.")
setStepErrors(eKinds)
if (Object.keys(eKinds).length) {
toast.error("Select valid stimulus and response component kinds.")
return
}
@ -233,7 +235,7 @@ export function CreateQuestionTypeFlow() {
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(body)
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
@ -290,20 +292,9 @@ export function CreateQuestionTypeFlow() {
{isEdit ? "Edit question type definition" : "Create question type definition"}
</h1>
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
{isEdit ? (
<>
Update definition{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">#{editDefinitionId}</code> via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">PUT /questions/type-definitions/:id</code>
.
</>
) : (
<>
Build a reusable dynamic question type (schema + kinds) for{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions. Data is sent to{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
{isEdit
? `Update reusable question type definition #${editDefinitionId}.`
: "Build a reusable question type template for dynamic practice and assessment questions."}
</p>
</div>
<div className="flex items-center gap-4 shrink-0">
@ -343,8 +334,6 @@ export function CreateQuestionTypeFlow() {
<QuestionTypeConfigStep
draft={draft}
setDraft={setDraft}
versionName={versionName}
setVersionName={setVersionName}
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
responseCatalogKinds={componentCatalog.response_component_kinds}
catalogLoading={catalogLoading}
@ -358,7 +347,12 @@ export function CreateQuestionTypeFlow() {
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
)}
{currentStep === 4 && (
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
<QuestionTypeReviewPublishStep
draft={draft}
onBack={handleBack}
editDefinitionId={editDefinitionId}
isSystem={isSystemDefinition}
/>
)}
</div>
</div>

View File

@ -787,7 +787,7 @@ export function HumanLanguageHierarchyPage() {
<div>
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
<p className="mt-1 text-sm text-grayScale-500">
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`.
Choose a sub-category from the list to view and manage its course structure.
</p>
</div>
</CardContent>
@ -1019,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader>
<DialogTitle>Create module</DialogTitle>
<DialogDescription>
Add a module to this level. This will call `POST /course-management/modules`.
Add a module to this level.
</DialogDescription>
</DialogHeader>
@ -1140,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader>
<DialogTitle>Update module</DialogTitle>
<DialogDescription>
Update this module using `PUT /course-management/modules/:moduleId`.
Update this module&apos;s name, order, and settings.
</DialogDescription>
</DialogHeader>

View File

@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
<DialogHeader>
<DialogTitle>Lesson detail</DialogTitle>
<DialogDescription>
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
View and edit lesson details.
</DialogDescription>
</DialogHeader>

View File

@ -356,15 +356,8 @@ export function LearnEnglishPage() {
Add New Program
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a learning program via{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /programs
</code>
. Thumbnail can be a URL or a file uploaded through{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
Create a new learning program. Add a thumbnail as an image URL or by uploading a
file.
</DialogDescription>
</DialogHeader>
{/* Gradient Divider */}
@ -739,11 +732,7 @@ export function LearnEnglishPage() {
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Local images are sent to{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the returned URL is stored as the program thumbnail.
Uploaded images are stored and used as the program thumbnail.
</p>
</div>
</div>

View File

@ -364,12 +364,6 @@ export function LessonPracticesPage() {
total={practices.length}
/>
))}
<p className="px-1 text-center text-[11px] text-grayScale-400">
Source:{" "}
<code className="rounded-md bg-grayScale-100 px-1.5 py-0.5 font-mono text-[10px] text-grayScale-500">
GET /lessons/{lid}/practices
</code>
</p>
</div>
)}
</div>

View File

@ -668,15 +668,7 @@ export function ModuleDetailPage() {
<DialogHeader>
<DialogTitle>Edit lesson</DialogTitle>
<DialogDescription>
Update details. Video and thumbnail files use{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
Update lesson details. Uploaded video and thumbnail files are stored automatically.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">

View File

@ -1093,9 +1093,6 @@ export function PracticeDetailsPage() {
placeholder="Optional"
/>
</div>
<p className="text-xs text-grayScale-500">
Uses <span className="font-mono">PUT /practices/&#123;id&#125;</span> with the fields above.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
@ -1115,9 +1112,8 @@ export function PracticeDetailsPage() {
<DialogTitle>Delete this practice?</DialogTitle>
</DialogHeader>
<p className="text-sm text-grayScale-600">
This will call <span className="font-mono">DELETE /practices/&#123;id&#125;</span> and remove the practice
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
cascades.
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
question set may remain unless you remove it separately.
</p>
<DialogFooter className="gap-2 sm:gap-0">
<Button

View File

@ -20,6 +20,7 @@ import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select"
import { Textarea } from "../../components/ui/textarea"
import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
@ -84,7 +85,7 @@ export function PracticeQuestionsPage() {
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
const [pageSize] = useState(10)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [currentPage, setCurrentPage] = useState(1)
const [totalQuestions, setTotalQuestions] = useState(0)
@ -736,29 +737,56 @@ export function PracticeQuestionsPage() {
))}
</div>
))}
{totalQuestions > pageSize && (
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
<p className="text-sm text-grayScale-500">
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => void fetchQuestions(currentPage - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
onClick={() => void fetchQuestions(currentPage + 1)}
>
Next
</Button>
{totalQuestions > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
total)
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setCurrentPage(1)
void fetchQuestions(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
{totalQuestions > pageSize ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => void fetchQuestions(currentPage - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
onClick={() => void fetchQuestions(currentPage + 1)}
>
Next
</Button>
</div>
) : null}
</div>
)}
</div>

View File

@ -379,15 +379,8 @@ export function ProgramCoursesPage() {
Add New Course
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a course via{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /programs/:program_id/courses
</code>
. Thumbnail can be a URL or a file from{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
Add a new course to this program. Use an image URL or upload a file for the
thumbnail.
</DialogDescription>
</DialogHeader>
@ -708,11 +701,7 @@ export function ProgramCoursesPage() {
<DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit course</DialogTitle>
<DialogDescription>
Update name, sort order, and thumbnail. Saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id
</code>
.
Update name, sort order, and thumbnail.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">

View File

@ -1,10 +1,21 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react"
import {
ArrowLeft,
ChevronDown,
ChevronLeft,
ChevronRight,
Layers,
Plus,
RefreshCw,
Search,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Card } from "../../components/ui/card"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import {
Dialog,
DialogContent,
@ -15,6 +26,7 @@ import {
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { QuestionTypeCard } from "./components/QuestionTypeCard"
import {
deleteQuestionTypeDefinition,
@ -22,6 +34,23 @@ import {
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type StatusFilter = "All" | "ACTIVE" | "INACTIVE"
type ScopeFilter = "all" | "system" | "custom"
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
]
const SCOPE_OPTIONS: { value: ScopeFilter; label: string }[] = [
{ value: "all", label: "All types" },
{ value: "system", label: "System only" },
{ value: "custom", label: "Custom only" },
]
const SYSTEM_SCOPE_FETCH_LIMIT = 100
export function QuestionTypeLibraryPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
@ -30,24 +59,48 @@ export function QuestionTypeLibraryPage() {
const [loading, setLoading] = useState(true)
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
const [query, setQuery] = useState("")
const [activeTab, setActiveTab] = useState<"All" | "ACTIVE" | "INACTIVE">("All")
const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [offset, setOffset] = useState(0)
const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
const hasActiveFilters =
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
const load = useCallback(async () => {
setLoading(true)
try {
const rows = await getQuestionTypeDefinitions({ include_system: true })
setDefinitions(Array.isArray(rows) ? rows : [])
const isSystemScope = scopeFilter === "system"
const { definitions: rows, total_count } = await getQuestionTypeDefinitions({
include_system: scopeFilter !== "custom",
...(statusFilter !== "All" ? { status: statusFilter } : {}),
limit: isSystemScope ? SYSTEM_SCOPE_FETCH_LIMIT : pageSize,
offset: isSystemScope ? 0 : offset,
})
const visibleRows = isSystemScope ? rows.filter((d) => d.is_system) : rows
setDefinitions(visibleRows)
if (isSystemScope) {
setTotalCount(visibleRows.length)
} else if (total_count != null) {
setTotalCount(total_count)
} else if (rows.length < pageSize) {
setTotalCount(offset + rows.length)
} else {
setTotalCount(undefined)
}
} catch (e) {
console.error(e)
toast.error("Failed to load question type definitions")
setDefinitions([])
setTotalCount(0)
} finally {
setLoading(false)
}
}, [])
}, [offset, pageSize, scopeFilter, statusFilter])
useEffect(() => {
void load()
@ -67,18 +120,36 @@ export function QuestionTypeLibraryPage() {
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return definitions
return definitions.filter((d) => {
if (activeTab !== "All") {
const st = (d.status || "").toString().toUpperCase()
if (activeTab === "ACTIVE" && st !== "ACTIVE") return false
if (activeTab === "INACTIVE" && st !== "INACTIVE") return false
}
if (!q) return true
const name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
})
}, [definitions, query, activeTab])
}, [definitions, query])
const isSystemScope = scopeFilter === "system"
const canPrev = !isSystemScope && offset > 0
const canNext =
!isSystemScope &&
(totalCount != null ? offset + pageSize < totalCount : definitions.length === pageSize)
const pageStart = totalCount === 0 ? 0 : isSystemScope ? 1 : offset + 1
const pageEnd =
isSystemScope
? filtered.length
: totalCount != null
? Math.min(offset + definitions.length, totalCount)
: offset + definitions.length
const resetPagination = () => setOffset(0)
const clearFilters = () => {
setQuery("")
setStatusFilter("All")
setScopeFilter("all")
setOffset(0)
}
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
@ -124,9 +195,7 @@ export function QuestionTypeLibraryPage() {
<div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
Reusable dynamic question type templates from{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/type-definitions</code>. Use them
when authoring <code className="text-xs bg-grayScale-100 px-1 rounded">DYNAMIC</code> questions.
Reusable templates that define how practice and assessment questions are structured and answered.
</p>
</div>
<Link to="/new-content/question-types/create">
@ -138,65 +207,240 @@ export function QuestionTypeLibraryPage() {
</div>
</div>
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
<Input
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
placeholder="Search by display name, key, or id…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-3 flex-wrap">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span>
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
<button
key={tab}
<Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
<CardHeader className="border-b border-grayScale-100 px-6 py-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
<Layers className="h-5 w-5" aria-hidden />
</div>
<div>
<CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
<p className="text-xs text-grayScale-500 mt-0.5">
Browse and filter templates from the question type catalog
</p>
</div>
</div>
<Button
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
activeTab === tab
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={loading}
onClick={() => void load()}
>
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
</button>
))}
</div>
</Card>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
</CardHeader>
{loading ? (
<p className="text-sm text-grayScale-500 px-2">Loading definitions</p>
) : filtered.length === 0 ? (
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl">
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p>
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filtered.map((d) => (
<QuestionTypeCard
key={d.id}
id={d.id}
definitionKey={d.key}
display_name={d.display_name}
status={d.status}
is_system={d.is_system}
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
responseKindsCount={d.response_component_kinds?.length ?? 0}
deleteDisabled={!!d.is_system}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
onDelete={() => openDeleteConfirm(d)}
/>
))}
</div>
)}
<CardContent className="p-0">
<div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
placeholder="Search by display name, key, or id on this page…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Status
</span>
{STATUS_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={statusFilter === value}
disabled={loading}
onClick={() => {
setStatusFilter(value)
resetPagination()
}}
/>
))}
</div>
<span className="hidden h-5 w-px bg-grayScale-200 sm:block" />
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Scope
</span>
{SCOPE_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={scopeFilter === value}
disabled={loading}
onClick={() => {
setScopeFilter(value)
resetPagination()
}}
/>
))}
</div>
</div>
{hasActiveFilters ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 self-start rounded-[8px] px-3 text-xs font-semibold text-grayScale-500 hover:text-brand-600 lg:self-center"
disabled={loading}
onClick={clearFilters}
>
Clear filters
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading definitions</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-grayScale-100 text-grayScale-400">
<Layers className="h-7 w-7" aria-hidden />
</div>
<p className="text-sm font-semibold text-grayScale-700">
{hasActiveFilters ? "No definitions match your filters" : "No definitions yet"}
</p>
<p className="max-w-sm text-xs text-grayScale-500">
{hasActiveFilters
? "Try different filters or clear them to see more results."
: "Create a definition to start building custom question templates."}
</p>
{hasActiveFilters ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px]"
onClick={clearFilters}
>
Clear filters
</Button>
) : (
<Link to="/new-content/question-types/create">
<Button size="sm" className="rounded-[8px] bg-brand-600 hover:bg-brand-500">
<Plus className="mr-2 h-4 w-4" />
Create definition
</Button>
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-5 px-6 py-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((d) => (
<QuestionTypeCard
key={d.id}
id={d.id}
definitionKey={d.key}
display_name={d.display_name}
status={d.status}
is_system={d.is_system}
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
responseKindsCount={d.response_component_kinds?.length ?? 0}
deleteDisabled={!!d.is_system}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
onDelete={() => openDeleteConfirm(d)}
/>
))}
</div>
)}
{!loading && (definitions.length > 0 || offset > 0) ? (
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-grayScale-100 bg-white px-6 py-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-500">
<span>
{totalCount != null
? `Showing ${pageStart}${pageEnd} of ${totalCount}`
: `Showing ${pageStart}${pageEnd}`}
</span>
{query.trim() && filtered.length !== definitions.length ? (
<span className="rounded-full bg-brand-50 px-2.5 py-0.5 text-[11px] font-semibold text-brand-600">
{filtered.length} match{filtered.length === 1 ? "" : "es"} on this page
</span>
) : null}
{!isSystemScope ? (
<>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-[8px] border border-grayScale-200 bg-white pl-2.5 pr-8 text-sm font-medium text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-brand-200"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</>
) : null}
</div>
{!isSystemScope ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canNext || loading}
onClick={() => setOffset((o) => o + pageSize)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
@ -244,3 +488,32 @@ export function QuestionTypeLibraryPage() {
</div>
)
}
function FilterChip({
label,
active,
disabled,
onClick,
}: {
label: string
active: boolean
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-sm shadow-brand-500/20"
: "border-grayScale-200 bg-white text-grayScale-600 hover:border-brand-200 hover:text-brand-600",
disabled && "pointer-events-none opacity-50",
)}
>
{label}
</button>
)
}

View File

@ -19,6 +19,7 @@ import { Badge } from "../../components/ui/badge"
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
import type { QuestionDetail } from "../../types/course.types"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
@ -558,7 +559,7 @@ export function QuestionsPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -30,6 +30,7 @@ import {
} from "../../components/ui/dropdown-menu"
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
@ -149,7 +150,7 @@ export function SpeakingPage() {
const [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
const [audioTotalCount, setAudioTotalCount] = useState(0)
const [audioPage, setAudioPage] = useState(1)
const [audioPageSize] = useState(12)
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
@ -1510,31 +1511,58 @@ export function SpeakingPage() {
))}
</div>
))}
{audioTotalCount > audioPageSize ? (
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
<p className="text-xs text-grayScale-500 sm:text-sm">
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage <= 1 || loading}
onClick={() => fetchAudioQuestions(audioPage - 1)}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
onClick={() => fetchAudioQuestions(audioPage + 1)}
>
Next
</Button>
{audioTotalCount > 0 ? (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-grayScale-200 bg-white px-3 py-2 text-xs text-grayScale-500 sm:text-sm">
<div className="flex flex-wrap items-center gap-2">
<span>
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} (
{audioTotalCount} total)
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={audioPageSize}
disabled={loading}
onChange={(e) => {
setAudioPageSize(Number(e.target.value))
void fetchAudioQuestions(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
{audioTotalCount > audioPageSize ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage <= 1 || loading}
onClick={() => fetchAudioQuestions(audioPage - 1)}
>
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
onClick={() => fetchAudioQuestions(audioPage + 1)}
>
Next
</Button>
</div>
) : null}
</div>
) : null}
</div>

View File

@ -111,11 +111,7 @@ export function AddModuleModal({
Add New Module
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-400">
Create a module with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /courses/:courseId/modules
</code>
.
Add a new module to this course.
</DialogDescription>
</DialogHeader>

View File

@ -265,8 +265,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
/>
</div>
<p className="text-xs text-grayScale-500">
This calls <span className="font-mono">POST /question-sets</span> with{" "}
<span className="font-mono">set_type: PRACTICE</span>.
Creates a practice question set for this course, module, or lesson.
</p>
<Button type="button" onClick={handleStep1} disabled={saving}>
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
@ -278,9 +277,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 2 && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> add
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
<span className="font-mono">POST /questions</span>.
Add one or more audio questions to question set #{questionSetId}.
</p>
{questionRows.map((row, idx) => (
<div
@ -389,8 +386,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 3 && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Link each question to the set with a display order using{" "}
<span className="font-mono">POST /question-sets/&#123;id&#125;/questions</span>.
Confirm the order of questions in the set.
</p>
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
{createdQuestionIds.map((qid, i) => (
@ -422,12 +418,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
{canUseWizard && step === 4 && parent && (
<div className="space-y-4">
<p className="text-sm text-grayScale-600">
Parent:{" "}
<span className="font-mono text-xs">
{parent.kind} #{parent.id}
</span>{" "}
· question set <span className="font-mono">#{questionSetId}</span> ·{" "}
<span className="font-mono">POST /practices</span>
Linked to {parent.kind.toLowerCase()} #{parent.id} · question set #{questionSetId}
</p>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">

View File

@ -1,4 +1,4 @@
import { Edit2, Trash2, Layers, Shield } from "lucide-react"
import { Edit2, Sparkles, Trash2, Layers, Shield } from "lucide-react"
import { Badge } from "../../../components/ui/badge"
import { Card } from "../../../components/ui/card"
import { Button } from "../../../components/ui/button"
@ -42,7 +42,12 @@ export function QuestionTypeCard({
<Shield className="h-3 w-3" />
System
</Badge>
) : null}
) : (
<Badge className="shrink-0 border-none bg-amber-50 text-amber-800 flex items-center gap-1">
<Sparkles className="h-3 w-3" />
Custom
</Badge>
)}
</div>
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>

View File

@ -15,6 +15,7 @@ interface ContextStepProps {
/** Lesson-linked practice: no title, story description, or story image on step 1. */
isLessonPractice?: boolean;
lessonTitle?: string | null;
parentSummary?: string | null;
}
/**
@ -27,6 +28,7 @@ export function ContextStep({
onCancel,
isLessonPractice = false,
lessonTitle = null,
parentSummary = null,
}: ContextStepProps) {
const storyFileRef = useRef<HTMLInputElement>(null);
const [uploadingStory, setUploadingStory] = useState(false);
@ -62,16 +64,15 @@ export function ContextStep({
<p className="text-grayScale-600 text-base mt-3">
{isLessonPractice ? (
<>
This practice is linked to{" "}
Story fields and question set options used when saving the practice. Linked to{" "}
<span className="font-medium text-grayScale-800">
{lessonTitle?.trim() || "the selected lesson"}
</span>
. Set optional quick tips and question order below.
.
</>
) : (
<>
Title, story, optional image, shuffle, and quick tips match the create
practice and question set APIs.
Story fields and question set options used when saving the practice.
</>
)}
</p>
@ -90,6 +91,16 @@ export function ContextStep({
</div>
<div className="space-y-8 p-10">
{parentSummary ? (
<div className="rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-3 text-sm text-grayScale-800">
<p className="font-semibold text-brand-700">LMS parent</p>
<p className="mt-1">{parentSummary}</p>
<p className="mt-1 text-xs text-grayScale-500">
The question set and practice will be linked to this course, module, or lesson.
</p>
</div>
) : null}
{!isLessonPractice ? (
<>
<div className="space-y-2">
@ -132,7 +143,7 @@ export function ContextStep({
onChange={(e) =>
setFormData({ ...formData, tips: e.target.value })
}
placeholder="Learner-facing tips (quick_tips on POST /practices)"
placeholder="Optional tips shown to learners before they start"
className="min-h-[80px] rounded-xl border-grayScale-200"
maxLength={1000}
/>

View File

@ -15,8 +15,7 @@ export function PublishStatusField({ value, onChange, disabled, className }: Pro
Publish status <span className="text-red-500">*</span>
</p>
<p className="text-xs text-grayScale-500">
Sent as <code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">publish_status</code> on{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">POST /practices</code>.
Controls whether learners can see this practice after you save.
</p>
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
{(

View File

@ -10,6 +10,8 @@ import {
emptyDynamicFieldValuesForDefinition,
legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion";
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
import { toast } from "sonner";
function defaultMcqOptions() {
return [
@ -98,9 +100,9 @@ export function QuestionsStep({
return (
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
<p className="text-xs leading-snug text-grayScale-600">
<span className="font-medium text-grayScale-800">Image / Audio</span> slots use upload or URL import (
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>
). Others: URL, text, or JSON.
<span className="font-medium text-grayScale-800">Image, audio, and PDF</span> use upload or URL.{" "}
<span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Timer and
prep-time slots use seconds. Other slots use text or structured JSON where noted.
</p>
{def.stimulus_schema.length > 0 ? (
<div className="space-y-2">
@ -307,11 +309,8 @@ export function QuestionsStep({
<div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
<p className="text-grayScale-400 text-lg">
Question types are loaded from{" "}
<code className="rounded bg-grayScale-100 px-1 text-sm">
GET /questions/type-definitions
</code>
. Pick a type per row, then fill the fields required for that definition.
Choose a question type for each item, then fill in the fields that type requires. Questions are saved
when you publish or save the practice.
</p>
</div>
@ -403,21 +402,28 @@ export function QuestionsStep({
) : null}
</div>
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Question text
</label>
<Input
value={q.text}
onChange={(e) => {
const newQuestions = [...formData.questions];
newQuestions[i].text = e.target.value;
setFormData({ ...formData, questions: newQuestions });
}}
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
placeholder="Question prompt for learners"
/>
</div>
{def && !definitionUsesDynamicPayload(def) ? (
<div className="space-y-3">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
Question text
</label>
<Input
value={q.text}
onChange={(e) => {
const newQuestions = [...formData.questions];
newQuestions[i].text = e.target.value;
setFormData({ ...formData, questions: newQuestions });
}}
className="min-h-[52px] rounded-xl border-grayScale-200 px-4 py-3 text-base font-medium text-grayScale-700"
placeholder="Question prompt for learners"
/>
</div>
) : def && definitionUsesDynamicPayload(def) ? (
<p className="rounded-lg border border-violet-200 bg-violet-50/60 px-3 py-2 text-xs text-violet-950">
Enter the question prompt in the text or instruction field below, along with any other
required content for this type.
</p>
) : null}
{def ? renderTypeSpecificFields(q, i, def) : null}
</div>
@ -451,7 +457,30 @@ export function QuestionsStep({
</Button>
<Button
type="button"
onClick={nextStep}
onClick={() => {
const mapped = formData.questions.map((row: typeof formData.questions[0]) => ({
questionText: String(row.text ?? "").trim(),
questionTypeDefinitionId: Number(row.questionTypeDefinitionId),
dynamicFieldValues: { ...(row.dynamicFieldValues ?? {}) },
mcqOptions: (row.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? "").trim(),
is_correct: Boolean(o.isCorrect),
}),
),
trueFalseAnswerIsTrue: row.trueFalseCorrect !== false,
shortAnswers: (row.shortAnswers ?? []).map((s: string) => String(s)),
}));
const msg = validateLearnEnglishQuestionsWithDefinitions(
mapped,
typeDefinitions,
);
if (msg) {
toast.error("Check your questions", { description: msg });
return;
}
nextStep();
}}
disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
>

View File

@ -0,0 +1,43 @@
import { AlertTriangle, Info } from "lucide-react"
import { inferRuntimeQuestionType } from "../../lib/questionTypeDefinitionValidation"
export function DefinitionRuntimeHint({
definitionKey,
responseKinds,
}: {
definitionKey: string
responseKinds: string[]
}) {
const runtime = inferRuntimeQuestionType(definitionKey, responseKinds)
if (runtime == null) {
return (
<div className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-600" />
<div>
<p className="font-semibold">May not be publishable</p>
<p className="mt-0.5 text-amber-800/90">
The server requires a mappable runtime question type. Add at least one non-timer response
kind (e.g. OPTION, TEXT_INPUT). Timer-only definitions are rejected.
</p>
</div>
</div>
)
}
return (
<div className="flex gap-3 rounded-xl border border-brand-100 bg-brand-50/50 px-4 py-3 text-sm text-grayScale-800">
<Info className="h-5 w-5 shrink-0 text-brand-500" />
<div>
<p className="font-semibold text-grayScale-900">
Stored as: {runtime === "DYNAMIC" ? "dynamic question" : runtime.replace(/_/g, " ").toLowerCase()}
</p>
<p className="mt-0.5 text-grayScale-600">
{runtime === "DYNAMIC"
? "Practice questions built from this type use the dynamic question builder."
: "Questions built from this type use the classic question format for this kind."}
</p>
</div>
</div>
)
}

View File

@ -29,10 +29,8 @@ export function QuestionTypeBasicInfoStep({
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
<p className="text-grayScale-500 font-medium mt-1">
Set the reusable key, display name, and status. On the next step you will pick stimulus and response
component types from the live catalog (
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
).
Set the reusable key, display name, and status. On the next step you will choose how questions are
presented and how learners answer.
</p>
</div>

View File

@ -1,12 +1,5 @@
import { useState } from "react"
import {
ArrowLeft,
ArrowRight,
ChevronDown,
ChevronUp,
Hourglass,
Plus,
} from "lucide-react"
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { Input } from "../../../../components/ui/input"
@ -16,14 +9,17 @@ import type {
} from "../../../../types/questionTypeDefinition.types"
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
import { SchemaBuilderSection } from "./SchemaBuilderSection"
import { SchemaSlotLabelsPanel } from "./SchemaSlotLabelsPanel"
import { ComponentKindCard } from "./ComponentKindCard"
import { getResponseKindPresentation, getStimulusKindPresentation } from "./componentKindUi"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
interface QuestionTypeConfigStepProps {
draft: QuestionTypeDefinitionCreatePayload
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
versionName: string
setVersionName: (v: string) => void
stimulusCatalogKinds: string[]
responseCatalogKinds: string[]
catalogLoading: boolean
@ -42,10 +38,6 @@ function slugFragmentFromKind(kind: string): string {
return s.replace(/^_|_$/g, "") || "field"
}
function defaultSchemaLabel(kind: string): string {
return kind.replace(/_/g, " ")
}
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
const base = slugFragmentFromKind(kind)
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
@ -58,6 +50,22 @@ function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: strin
return id
}
function uniqueKindsFromSchemaRows(rows: DynamicElementDefinition[]): string[] {
return [...new Set(rows.map((r) => r.kind).filter(Boolean))]
}
function removeLastSlotOfKind(
rows: DynamicElementDefinition[],
kind: string,
): DynamicElementDefinition[] {
let removeIndex = -1
rows.forEach((row, index) => {
if (row.kind === kind) removeIndex = index
})
if (removeIndex < 0) return rows
return rows.filter((_, index) => index !== removeIndex)
}
function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Record<number, string> {
const prefix = `${side}_`
const out: Record<number, string> = {}
@ -73,8 +81,6 @@ function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Reco
export function QuestionTypeConfigStep({
draft,
setDraft,
versionName,
setVersionName,
stimulusCatalogKinds,
responseCatalogKinds,
catalogLoading,
@ -83,11 +89,8 @@ export function QuestionTypeConfigStep({
onNext,
onBack,
}: QuestionTypeConfigStepProps) {
const [panelOpen, setPanelOpen] = useState(true)
const [advancedOpen, setAdvancedOpen] = useState(false)
const title = draft.display_name?.trim() || "Untitled definition"
const handleStimulusKindClick = (kind: string) => {
setDraft((d) => {
const wasSelected = d.stimulus_component_kinds.includes(kind)
@ -98,7 +101,7 @@ export function QuestionTypeConfigStep({
stimulus_schema.push({
id: nextUniqueSchemaElementId(stimulus_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
}
@ -122,7 +125,7 @@ export function QuestionTypeConfigStep({
response_schema.push({
id: nextUniqueSchemaElementId(response_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
}
@ -143,7 +146,7 @@ export function QuestionTypeConfigStep({
stimulus_schema.push({
id: nextUniqueSchemaElementId(stimulus_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
return { ...d, stimulus_schema }
@ -157,54 +160,63 @@ export function QuestionTypeConfigStep({
response_schema.push({
id: nextUniqueSchemaElementId(response_schema, kind),
kind,
label: defaultSchemaLabel(kind),
label: defaultLabelForKind(kind),
required: true,
})
return { ...d, response_schema }
})
}
const removeStimulusSlot = (kind: string) => {
setDraft((d) => {
const stimulus_schema = removeLastSlotOfKind(d.stimulus_schema, kind)
return {
...d,
stimulus_schema,
stimulus_component_kinds: uniqueKindsFromSchemaRows(stimulus_schema),
}
})
}
const removeResponseSlot = (kind: string) => {
setDraft((d) => {
const response_schema = removeLastSlotOfKind(d.response_schema, kind)
return {
...d,
response_schema,
response_component_kinds: uniqueKindsFromSchemaRows(response_schema),
}
})
}
const setStimulusSchema = (rows: DynamicElementDefinition[]) => {
setDraft((d) => ({
...d,
stimulus_schema: rows,
stimulus_component_kinds: uniqueKindsFromSchemaRows(rows),
}))
}
const setResponseSchema = (rows: DynamicElementDefinition[]) => {
setDraft((d) => ({
...d,
response_schema: rows,
response_component_kinds: uniqueKindsFromSchemaRows(rows),
}))
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
className="w-full flex items-center justify-between gap-4 px-5 py-4 bg-violet-100/90 hover:bg-violet-100 border-b border-violet-200/80 text-left transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="h-10 w-10 rounded-xl bg-white/80 flex items-center justify-center text-violet-700 shrink-0 shadow-sm">
<Hourglass className="h-5 w-5" />
</div>
<span className="text-[17px] font-bold text-grayScale-900 truncate">{title}</span>
</div>
{panelOpen ? (
<ChevronUp className="h-5 w-5 text-grayScale-600 shrink-0" />
) : (
<ChevronDown className="h-5 w-5 text-grayScale-600 shrink-0" />
)}
</button>
{panelOpen ? (
<div className="p-6 sm:p-10 space-y-10">
<div className="space-y-2 max-w-xl">
<label className="text-[14px] font-semibold text-grayScale-700">
Version name <span className="text-red-500">*</span>
</label>
<Input
className="h-11 rounded-[10px] border-grayScale-200 bg-[#F8FAFC]"
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder="e.g. Test 1"
/>
{errors.version_name ? (
<p className="text-sm font-medium text-red-600">{errors.version_name}</p>
) : null}
<p className="text-[12px] text-grayScale-400">
Local label for this authoring pass (not sent to the API unless you add it to description later).
</p>
</div>
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 2: Input &amp; answer types</h2>
<p className="text-grayScale-500 font-medium mt-1">
Choose what learners see in the question and how they respond. Add or remove slots for each type
as needed.
</p>
</div>
<div className="p-6 sm:p-10 space-y-10">
{catalogLoading ? (
<p className="text-sm text-grayScale-500">Loading component catalog</p>
) : catalogError ? (
@ -217,10 +229,8 @@ export function QuestionTypeConfigStep({
Section A: Question input types
</h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
Choose how the question is presented to the learner. The API lists each kind once in{" "}
<code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_component_kinds</code>{" "}
while <code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_schema</code> can
include the same kind multiple times (different ids).
Choose how the question is presented to the learner. Use Add slot / Remove slot to adjust
how many fields of each type you need.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -241,16 +251,31 @@ export function QuestionTypeConfigStep({
<span className="text-[12px] text-grayScale-500 font-medium">
{slotCount} slot{slotCount === 1 ? "" : "s"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
onClick={() => addStimulusSlot(kind)}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add slot
</Button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
onClick={() => removeStimulusSlot(kind)}
disabled={slotCount === 0}
aria-label={`Remove ${label} slot`}
>
<Minus className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
onClick={() => addStimulusSlot(kind)}
aria-label={`Add ${label} slot`}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</div>
</div>
) : null}
</div>
@ -268,10 +293,8 @@ export function QuestionTypeConfigStep({
Section B: Answer types
</h3>
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
How should the student answer?{" "}
<code className="text-[11px] bg-grayScale-100 px-1 rounded">response_component_kinds</code> is
deduplicated; use <span className="font-medium text-grayScale-600">Add slot</span> for multiple
fields of the same kind.
How should the student answer? Use Add slot / Remove slot to adjust how many answer fields
each type needs.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
@ -292,16 +315,31 @@ export function QuestionTypeConfigStep({
<span className="text-[12px] text-grayScale-500 font-medium">
{slotCount} slot{slotCount === 1 ? "" : "s"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
onClick={() => addResponseSlot(kind)}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add slot
</Button>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
onClick={() => removeResponseSlot(kind)}
disabled={slotCount === 0}
aria-label={`Remove ${label} slot`}
>
<Minus className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
onClick={() => addResponseSlot(kind)}
aria-label={`Add ${label} slot`}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</div>
</div>
) : null}
</div>
@ -315,6 +353,14 @@ export function QuestionTypeConfigStep({
</div>
)}
<SchemaSlotLabelsPanel
stimulusRows={draft.stimulus_schema}
responseRows={draft.response_schema}
onStimulusChange={setStimulusSchema}
onResponseChange={setResponseSchema}
errors={errors}
/>
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 overflow-hidden">
<button
type="button"
@ -336,7 +382,7 @@ export function QuestionTypeConfigStep({
allowedKinds={draft.stimulus_component_kinds}
catalogKinds={stimulusCatalogKinds}
rows={draft.stimulus_schema}
onChange={(rows) => setDraft((d) => ({ ...d, stimulus_schema: rows }))}
onChange={setStimulusSchema}
error={errors.stimulus_schema}
rowErrors={rowErrorMap("stimulus", errors)}
/>
@ -346,15 +392,14 @@ export function QuestionTypeConfigStep({
allowedKinds={draft.response_component_kinds}
catalogKinds={responseCatalogKinds}
rows={draft.response_schema}
onChange={(rows) => setDraft((d) => ({ ...d, response_schema: rows }))}
onChange={setResponseSchema}
error={errors.response_schema}
rowErrors={rowErrorMap("response", errors)}
/>
</div>
) : null}
</div>
</div>
) : null}
</div>
<div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
<Button

View File

@ -11,30 +11,54 @@ import {
validateQuestionTypeDefinition,
} from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
import {
buildCreatePayload,
buildValidateKindsPayload,
inferRuntimeQuestionType,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
import { slotLabel } from "./componentKindUi"
interface QuestionTypeReviewPublishStepProps {
draft: QuestionTypeDefinitionCreatePayload
onBack: () => void
/** When set, saves via PUT /questions/type-definitions/:id */
editDefinitionId?: number | null
isSystem?: boolean
}
export function QuestionTypeReviewPublishStep({
draft,
onBack,
editDefinitionId,
isSystem,
}: QuestionTypeReviewPublishStepProps) {
const navigate = useNavigate()
const [submitting, setSubmitting] = useState(false)
const isEdit = editDefinitionId != null && editDefinitionId > 0
const payload = buildCreatePayload(draft)
const runtime = inferRuntimeQuestionType(payload.key, payload.response_component_kinds)
const submit = async (status: "ACTIVE" | "INACTIVE") => {
if (runtime == null) {
toast.error("Definition cannot be saved", {
description: "Add at least one non-timer response kind so the server can map a runtime question type.",
})
return
}
const body = { ...payload, status }
setSubmitting(true)
try {
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
if (isEdit) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
@ -45,14 +69,6 @@ export function QuestionTypeReviewPublishStep({
return
}
const validation = await validateQuestionTypeDefinition(body)
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
@ -81,30 +97,30 @@ export function QuestionTypeReviewPublishStep({
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review & publish</h2>
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 4: Review &amp; publish</h2>
<p className="text-grayScale-500 font-medium mt-1">
{isEdit ? (
<>
Confirm changes, then update via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">
PUT /questions/type-definitions/{editDefinitionId}
</code>
.
</>
) : (
<>
Confirm details, then create via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/type-definitions</code>.
</>
)}
{isEdit
? "Confirm your changes and save. The definition key cannot be changed."
: "Confirm your definition, then save it for use when authoring practice questions."}
</p>
</div>
<div className="p-10 space-y-6">
{isSystem ? (
<p className="text-sm font-medium text-amber-800 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3">
This is a system definition. You can update it, but it cannot be deleted from the library.
</p>
) : null}
<DefinitionRuntimeHint
definitionKey={payload.key}
responseKinds={payload.response_component_kinds}
/>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.key}</dd>
<dd className="font-medium text-grayScale-900 mt-1 font-mono text-[13px]">{payload.key}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt>
@ -118,6 +134,10 @@ export function QuestionTypeReviewPublishStep({
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Status</dt>
<dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Runtime type</dt>
<dd className="font-medium text-grayScale-900 mt-1">{runtime ?? "Unmappable"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_component_kinds.join(", ") || "—"}</dd>
@ -126,33 +146,42 @@ export function QuestionTypeReviewPublishStep({
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response kinds</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.stimulus_schema.length}</dd>
</div>
<div>
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Response schema rows</dt>
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_schema.length}</dd>
</div>
</dl>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<SchemaSlotSummary title="Stimulus schema" rows={payload.stimulus_schema} />
<SchemaSlotSummary title="Response schema" rows={payload.response_schema} />
</div>
<div className="flex flex-wrap gap-3 pt-2">
<Button
type="button"
variant="outline"
className="h-11"
disabled={submitting}
disabled={submitting || runtime == null}
onClick={() => void submit("INACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as inactive" : "Save as inactive (draft)"}
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save as inactive"
) : (
"Save as inactive (draft)"
)}
</Button>
<Button
type="button"
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
disabled={submitting}
disabled={submitting || runtime == null}
onClick={() => void submit("ACTIVE")}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : isEdit ? "Save as active" : "Create as active"}
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save as active"
) : (
"Create as active"
)}
</Button>
</div>
</div>
@ -173,3 +202,35 @@ export function QuestionTypeReviewPublishStep({
</div>
)
}
function SchemaSlotSummary({
title,
rows,
}: {
title: string
rows: { id: string; kind: string; label?: string; required: boolean }[]
}) {
return (
<div className="rounded-xl border border-grayScale-100 bg-grayScale-50/50 p-4">
<h4 className="text-[12px] font-bold uppercase tracking-wide text-grayScale-500">{title}</h4>
{rows.length === 0 ? (
<p className="mt-2 text-sm text-grayScale-500">No slots</p>
) : (
<ul className="mt-2 space-y-1.5 text-sm">
{rows.map((r) => (
<li key={`${r.kind}-${r.id}`} className="flex flex-wrap gap-x-2 text-grayScale-800">
<span className="font-medium">{slotLabel(r)}</span>
<span className="text-grayScale-400">·</span>
<span className="font-mono text-[11px] text-grayScale-500">{r.id}</span>
<span className="text-grayScale-400">·</span>
<span className="text-grayScale-600">{r.kind}</span>
{r.required ? (
<span className="text-[10px] font-bold uppercase text-brand-600">required</span>
) : null}
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -1,11 +1,15 @@
import { useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../../../components/ui/button"
import { Card } from "../../../../components/ui/card"
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
import {
buildCreatePayload,
buildValidateKindsPayload,
} from "../../lib/questionTypeDefinitionValidation"
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
interface QuestionTypeValidatePreviewStepProps {
draft: QuestionTypeDefinitionCreatePayload
@ -25,12 +29,12 @@ export function QuestionTypeValidatePreviewStep({
const payload = buildCreatePayload(draft)
const json = JSON.stringify(payload, null, 2)
const runValidate = async () => {
const runValidate = useCallback(async () => {
setValidating(true)
setServerOk(null)
setServerDetail(null)
try {
const res = await validateQuestionTypeDefinition(payload)
const res = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
if (!res.valid) {
setServerOk(false)
const detail = res.error || res.message || "Validation failed"
@ -39,32 +43,49 @@ export function QuestionTypeValidatePreviewStep({
return
}
setServerOk(true)
setServerDetail(res.message || "Question type definition is valid.")
toast.success(res.message || "Question type definition is valid")
setServerDetail(res.message || "Component kinds are valid.")
toast.success(res.message || "Definition kinds validated")
} finally {
setValidating(false)
}
}, [draft])
useEffect(() => {
void runValidate()
}, [runValidate])
const handleNext = () => {
if (serverOk !== true) {
toast.error("Validate with the server before continuing.", {
description: serverDetail || "Fix response/stimulus kinds and try again.",
})
return
}
onNext()
}
return (
<div className="space-y-8 pb-32">
<Card className="max-w-4xl mx-auto overflow-hidden border-grayScale-100 shadow-sm rounded-2xl bg-white">
<div className="p-10 border-b border-grayScale-200">
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate & preview</h2>
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 3: Validate</h2>
<p className="text-grayScale-500 font-medium mt-1">
Optional server check via{" "}
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code>
. Uses the same JSON as create; validity comes from <code className="text-xs bg-grayScale-100 px-1 rounded">data.valid</code>{" "}
(not the envelope <code className="text-xs bg-grayScale-100 px-1 rounded">success</code> flag).
We check that your stimulus and response selections are valid before you continue. You must pass
validation before review.
</p>
</div>
<div className="p-10 space-y-6">
<DefinitionRuntimeHint
definitionKey={payload.key}
responseKinds={payload.response_component_kinds}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
onClick={runValidate}
onClick={() => void runValidate()}
disabled={validating}
className="h-10"
>
@ -74,7 +95,7 @@ export function QuestionTypeValidatePreviewStep({
Validating
</>
) : (
"Validate with server"
"Re-validate"
)}
</Button>
{serverOk === true ? (
@ -83,12 +104,49 @@ export function QuestionTypeValidatePreviewStep({
{serverDetail}
</span>
) : null}
{serverOk === false ? <span className="text-sm font-medium text-red-600">{serverDetail}</span> : null}
{serverOk === false ? (
<span className="text-sm font-medium text-red-600">{serverDetail}</span>
) : validating ? (
<span className="text-sm text-grayScale-500">Checking kinds with server</span>
) : null}
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm rounded-xl border border-grayScale-100 bg-grayScale-50/60 p-4">
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus kinds</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.stimulus_component_kinds.join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Response kinds</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.response_component_kinds.join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Stimulus slots</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.stimulus_schema.length
? payload.stimulus_schema.map((r) => r.label).join(" · ")
: "—"}
</dd>
</div>
<div>
<dt className="text-[11px] font-bold uppercase text-grayScale-400">Response slots</dt>
<dd className="mt-1 font-medium text-grayScale-800">
{payload.response_schema.length
? payload.response_schema.map((r) => r.label).join(" · ")
: "—"}
</dd>
</div>
</dl>
<div className="space-y-2">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p>
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[420px] overflow-y-auto">
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
Definition preview
</p>
<pre className="text-[12px] leading-relaxed font-mono bg-grayScale-900 text-grayScale-50 rounded-xl p-4 overflow-x-auto max-h-[360px] overflow-y-auto">
{json}
</pre>
</div>
@ -106,10 +164,11 @@ export function QuestionTypeValidatePreviewStep({
</Button>
<Button
type="button"
onClick={onNext}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"
onClick={handleNext}
disabled={validating || serverOk !== true}
className="h-10 px-10 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] disabled:opacity-50 transition-all flex items-center gap-3"
>
Next: Review
Next: Review & publish
<ArrowRight className="h-5 w-5" />
</Button>
</div>

View File

@ -4,6 +4,11 @@ import { Input } from "../../../../components/ui/input"
import { Textarea } from "../../../../components/ui/textarea"
import { Select } from "../../../../components/ui/select"
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
type Side = "stimulus" | "response"
@ -23,7 +28,7 @@ function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
return {
id: "",
kind: first,
label: "",
label: first ? defaultLabelForKind(first) : "",
required: true,
config: undefined,
}
@ -43,10 +48,24 @@ export function SchemaBuilderSection({
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
const next = rows.map((r, i) => (i === index ? { ...r, ...patch } : r))
const next = rows.map((r, i) => {
if (i !== index) return r
const merged = { ...r, ...patch }
if (patch.kind && patch.kind !== r.kind) {
const priorDefault = defaultLabelForKind(r.kind)
const current = (r.label ?? "").trim()
if (!current || current === priorDefault) {
merged.label = defaultLabelForKind(patch.kind)
}
}
return merged
})
onChange(next)
}
const kindPresentation = (kind: string) =>
side === "stimulus" ? getStimulusKindPresentation(kind) : getResponseKindPresentation(kind)
const commitConfigString = (index: number, raw: string) => {
const trimmed = raw.trim()
if (!trimmed) {
@ -73,9 +92,8 @@ export function SchemaBuilderSection({
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
<p className="text-[13px] text-grayScale-500 mt-0.5">
Each row defines one element in the{" "}
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
config).
Fine-tune slot ids, labels, required flags, and optional config. Labels are the field titles
authors see when creating questions.
</p>
</div>
<Button
@ -145,7 +163,7 @@ export function SchemaBuilderSection({
>
{kindOptions.map((k) => (
<option key={k} value={k}>
{k}
{kindPresentation(k).label} ({k})
</option>
))}
</Select>
@ -154,11 +172,13 @@ export function SchemaBuilderSection({
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-[12px] font-semibold text-grayScale-600">Label</label>
<label className="text-[12px] font-semibold text-grayScale-600">
Field label <span className="text-red-500">*</span>
</label>
<Input
value={row.label ?? ""}
onChange={(e) => updateRow(index, { label: e.target.value })}
placeholder="Author-facing label"
placeholder={defaultLabelForKind(row.kind)}
className="h-10 bg-white"
/>
</div>

View File

@ -0,0 +1,137 @@
import { Trash2 } from "lucide-react"
import { Button } from "../../../../components/ui/button"
import { Input } from "../../../../components/ui/input"
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
import {
defaultLabelForKind,
getResponseKindPresentation,
getStimulusKindPresentation,
} from "./componentKindUi"
interface SchemaSlotLabelsPanelProps {
stimulusRows: DynamicElementDefinition[]
responseRows: DynamicElementDefinition[]
onStimulusChange: (rows: DynamicElementDefinition[]) => void
onResponseChange: (rows: DynamicElementDefinition[]) => void
errors?: Record<string, string>
}
function updateRowLabel(
rows: DynamicElementDefinition[],
index: number,
label: string,
): DynamicElementDefinition[] {
return rows.map((row, i) => (i === index ? { ...row, label } : row))
}
function removeRow(rows: DynamicElementDefinition[], index: number): DynamicElementDefinition[] {
return rows.filter((_, i) => i !== index)
}
function SlotLabelGroup({
title,
side,
rows,
onChange,
errors,
}: {
title: string
side: "stimulus" | "response"
rows: DynamicElementDefinition[]
onChange: (rows: DynamicElementDefinition[]) => void
errors?: Record<string, string>
}) {
if (!rows.length) return null
return (
<div className="space-y-3">
<h4 className="text-[12px] font-bold uppercase tracking-wide text-grayScale-500">{title}</h4>
<div className="space-y-2">
{rows.map((row, index) => {
const presentation =
side === "stimulus"
? getStimulusKindPresentation(row.kind)
: getResponseKindPresentation(row.kind)
const rowError = errors?.[`${side}_${index}`]
return (
<div
key={`${side}-${row.id}-${index}`}
className="rounded-xl border border-grayScale-200 bg-white p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12px] text-grayScale-500">
<span className="font-semibold text-grayScale-700">{presentation.label}</span>
<span className="text-grayScale-300">·</span>
<span className="font-mono text-[11px]">{row.id}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0 text-grayScale-500 hover:text-red-600"
onClick={() => onChange(removeRow(rows, index))}
aria-label={`Remove ${presentation.label} slot`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<label className="text-[12px] font-semibold text-grayScale-600">
Field label <span className="text-red-500">*</span>
</label>
<Input
value={row.label ?? ""}
onChange={(e) => onChange(updateRowLabel(rows, index, e.target.value))}
placeholder={defaultLabelForKind(row.kind)}
className="h-10 bg-white"
/>
<p className="text-[11px] text-grayScale-400">
Shown to authors when they create questions from this type.
</p>
</div>
{rowError ? <p className="text-sm text-red-600">{rowError}</p> : null}
</div>
)
})}
</div>
</div>
)
}
export function SchemaSlotLabelsPanel({
stimulusRows,
responseRows,
onStimulusChange,
onResponseChange,
errors,
}: SchemaSlotLabelsPanelProps) {
if (!stimulusRows.length && !responseRows.length) return null
return (
<div className="rounded-xl border border-grayScale-200 bg-[#F8FAFC] p-5 space-y-6">
<div>
<h3 className="text-[16px] font-bold text-grayScale-900">Field labels</h3>
<p className="text-[13px] text-grayScale-500 mt-0.5">
Name each schema slot for question authors, or remove slots you no longer need. Labels are stored on
the definition and are not sent inside question payloads.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SlotLabelGroup
title="Question content (stimulus)"
side="stimulus"
rows={stimulusRows}
onChange={onStimulusChange}
errors={errors}
/>
<SlotLabelGroup
title="Learner answer (response)"
side="response"
rows={responseRows}
onChange={onResponseChange}
errors={errors}
/>
</div>
</div>
)
}

View File

@ -20,34 +20,35 @@ import {
Volume2,
} from "lucide-react"
/** Human label for API kind codes; unknown kinds fall back to title-cased code. */
import { defaultLabelForKind, humanizeKind, slotLabel } from "../../../../lib/schemaSlotLabel"
export { defaultLabelForKind, humanizeKind, slotLabel }
const STIMULUS_LABELS: Record<string, string> = {
QUESTION_TEXT: "Question Text",
PREP_TIME: "Prep Time",
INSTRUCTION: "Instruction",
AUDIO_PROMPT: "Audio Prompt",
AUDIO_CLIP: "Audio Clip",
TEXT_PASSAGE: "Text Passage",
IMAGE: "Image",
CHART: "Chart",
MATCHING_INPUTS: "Matching Inputs",
SELECT_MISSING_WORDS: "Select Missing Words",
TABLE: "Table",
FLOW_CHART: "Flow Chart",
QUESTION_TEXT: defaultLabelForKind("QUESTION_TEXT"),
PREP_TIME: defaultLabelForKind("PREP_TIME"),
INSTRUCTION: defaultLabelForKind("INSTRUCTION"),
AUDIO_PROMPT: defaultLabelForKind("AUDIO_PROMPT"),
TEXT_PASSAGE: defaultLabelForKind("TEXT_PASSAGE"),
IMAGE: defaultLabelForKind("IMAGE"),
MATCHING_INPUTS: defaultLabelForKind("MATCHING_INPUTS"),
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
TABLE: defaultLabelForKind("TABLE"),
PDF_ATTACHMENT: defaultLabelForKind("PDF_ATTACHMENT"),
}
const RESPONSE_LABELS: Record<string, string> = {
AUDIO_RESPONSE: "Audio Response",
TEXT_INPUT: "Text Input",
SHORT_ANSWER: "Short Answer",
MULTIPLE_CHOICE: "Multiple Choice",
OPTION: "Options",
ANSWER_TIMER: "Answer Timer",
SELECT_MISSING_WORDS: "Select Missing Words",
PDF_UPLOAD: "PDF Upload",
MATCHING_ANSWER: "Matching Answer",
LABEL_SELECTION: "Label Selection",
SEQUENCE_ORDER: "Sequence Order",
AUDIO_RESPONSE: defaultLabelForKind("AUDIO_RESPONSE"),
TEXT_INPUT: defaultLabelForKind("TEXT_INPUT"),
SHORT_ANSWER: defaultLabelForKind("SHORT_ANSWER"),
MULTIPLE_CHOICE: defaultLabelForKind("MULTIPLE_CHOICE"),
OPTION: defaultLabelForKind("OPTION"),
ANSWER_TIMER: defaultLabelForKind("ANSWER_TIMER"),
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
PDF_UPLOAD: defaultLabelForKind("PDF_UPLOAD"),
MATCHING_ANSWER: defaultLabelForKind("MATCHING_ANSWER"),
LABEL_SELECTION: defaultLabelForKind("LABEL_SELECTION"),
SEQUENCE_ORDER: defaultLabelForKind("SEQUENCE_ORDER"),
}
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */
@ -59,11 +60,10 @@ const STIMULUS_ICONS: Record<string, LucideIcon> = {
AUDIO_CLIP: Volume2,
TEXT_PASSAGE: FileText,
IMAGE: ImageIcon,
CHART: BarChart3,
MATCHING_INPUTS: Link2,
SELECT_MISSING_WORDS: ListTodo,
TABLE: TableIcon,
FLOW_CHART: GitBranch,
PDF_ATTACHMENT: FileUp,
}
const RESPONSE_ICONS: Record<string, LucideIcon> = {
@ -82,13 +82,6 @@ const RESPONSE_ICONS: Record<string, LucideIcon> = {
const DEFAULT_ICON = FileText
function humanizeKind(kind: string): string {
return kind
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function getStimulusKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
return {
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),

View File

@ -104,12 +104,8 @@ export function VideoDetailStep({
Video
</h3>
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
Upload a file or paste a link (Vimeo, hosted file, etc.). Files are
sent to your storage via{" "}
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
POST /files/upload
</code>
.
Upload a file or paste a link (Vimeo, hosted file, etc.). Uploaded files are stored
automatically.
</p>
<LessonMediaUploadField
kind="video"
@ -260,12 +256,8 @@ export function VideoDetailStep({
Pro tip
</h3>
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
Use clear titles and a thumbnail that matches the lesson. The
lesson is created with{" "}
<code className="rounded bg-white/80 px-1 text-[10px]">
POST /modules/:moduleId/lessons
</code>{" "}
when you publish.
Use clear titles and a thumbnail that matches the lesson. The lesson is created when
you publish.
</p>
</div>
</div>

View File

@ -3,6 +3,7 @@ import type {
QuestionComponentCatalog,
QuestionTypeDefinitionCreatePayload,
} from "../../../types/questionTypeDefinition.types"
import { defaultLabelForKind } from "../../../lib/schemaSlotLabel"
export type FieldErrorMap = Record<string, string>
@ -41,6 +42,18 @@ export function validateDefinitionKinds(
errors.response_kinds = "ANSWER_TIMER cannot be the only response kind."
}
const prepCount = sk.filter((k) => k === "PREP_TIME").length
if (prepCount > 1) {
errors.stimulus_kinds = "At most one PREP_TIME is allowed."
}
const timerCount = rk.filter((k) => k === "ANSWER_TIMER").length
if (timerCount > 1) {
errors.response_kinds = errors.response_kinds
? `${errors.response_kinds} At most one ANSWER_TIMER is allowed.`
: "At most one ANSWER_TIMER is allowed."
}
if (catalog) {
const sCat = new Set(catalog.stimulus_component_kinds)
const rCat = new Set(catalog.response_component_kinds)
@ -87,13 +100,18 @@ export function validateDefinitionSchemas(
const allowedSet = new Set(allowed)
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
rows.forEach((row, i) => {
if (!row.kind) errors[`${prefix}_${i}`] = "Kind is required."
const rowMessages: string[] = []
if (!row.kind) rowMessages.push("Kind is required.")
else if (allowedSet.size && !allowedSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in selected ${side} kinds.`
rowMessages.push(`Kind "${row.kind}" is not in selected ${side} kinds.`)
}
if (catalogSet.size && row.kind && !catalogSet.has(row.kind)) {
errors[`${prefix}_${i}`] = `Kind "${row.kind}" is not in the ${side} component catalog.`
rowMessages.push(`Kind "${row.kind}" is not in the ${side} component catalog.`)
}
if (!row.label?.trim()) {
rowMessages.push("Label is required — this is the field title authors see when creating questions.")
}
if (rowMessages.length) errors[`${prefix}_${i}`] = rowMessages.join(" ")
})
}
@ -126,6 +144,42 @@ function uniqueKindsFromSchemaRows(rows: DynamicElementDefinition[]): string[] {
return [...set].sort((a, b) => a.localeCompare(b))
}
const AUXILIARY_RESPONSE_KINDS = new Set(["ANSWER_TIMER"])
const SHORT_ANSWER_RESPONSE_KINDS = new Set([
"SHORT_ANSWER",
"TEXT_INPUT",
"SELECT_MISSING_WORDS",
"MATCHING_ANSWER",
"LABEL_SELECTION",
"PDF_UPLOAD",
])
/** Mirrors server runtime mapping (§13). Returns null when create would fail as unmappable. */
export function inferRuntimeQuestionType(
key: string,
responseKinds: string[],
): "TRUE_FALSE" | "AUDIO" | "MCQ" | "SHORT_ANSWER" | "DYNAMIC" | null {
const normalizedKey = key.trim().toLowerCase()
if (normalizedKey === "true_false") return "TRUE_FALSE"
if (responseKinds.includes("AUDIO_RESPONSE")) return "AUDIO"
if (responseKinds.includes("MULTIPLE_CHOICE")) return "MCQ"
const nonAuxiliary = responseKinds.filter((k) => !AUXILIARY_RESPONSE_KINDS.has(k))
if (nonAuxiliary.some((k) => SHORT_ANSWER_RESPONSE_KINDS.has(k))) return "SHORT_ANSWER"
if (nonAuxiliary.length > 0) return "DYNAMIC"
return null
}
/** POST /questions/validate-question-type-definition — kinds only. */
export function buildValidateKindsPayload(
draft: QuestionTypeDefinitionCreatePayload,
): Pick<QuestionTypeDefinitionCreatePayload, "stimulus_component_kinds" | "response_component_kinds"> {
const payload = buildCreatePayload(draft)
return {
stimulus_component_kinds: payload.stimulus_component_kinds,
response_component_kinds: payload.response_component_kinds,
}
}
export function buildCreatePayload(
draft: QuestionTypeDefinitionCreatePayload,
): QuestionTypeDefinitionCreatePayload {
@ -133,14 +187,14 @@ export function buildCreatePayload(
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
label: r.label?.trim() || defaultLabelForKind(r.kind),
config: r.config && Object.keys(r.config).length ? r.config : undefined,
}))
const response_schema = draft.response_schema.map((r) => ({
...r,
id: r.id.trim(),
kind: r.kind.trim(),
label: r.label?.trim() || undefined,
label: r.label?.trim() || defaultLabelForKind(r.kind),
config: r.config && Object.keys(r.config).length ? r.config : undefined,
}))

View File

@ -41,6 +41,7 @@ import {
DialogDescription,
} from "../../components/ui/dialog";
import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import {
getIssues,
@ -654,7 +655,7 @@ export function IssuesPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -119,11 +119,7 @@ export function CreateEmailTemplatePage() {
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.
Create a custom email template. System templates are managed separately.
</p>
</div>

View File

@ -78,11 +78,8 @@ export function EmailTemplatesPage() {
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.
View and edit email templates used for learner and team notifications. Open a template to
preview and edit its content.
</p>
</div>
<div className="flex flex-wrap gap-2">

View File

@ -3,17 +3,9 @@ import {
Bell,
BellOff,
AlertTriangle,
Info,
AlertCircle,
CheckCircle2,
ChevronLeft,
ChevronRight,
Megaphone,
UserPlus,
CreditCard,
BookOpen,
Video,
ShieldAlert,
MailOpen,
Mail,
CheckCheck,
@ -53,6 +45,7 @@ import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { useNavigate } from "react-router-dom"
import {
getNotificationById,
getNotifications,
getUnreadCount,
markAsRead,
@ -63,6 +56,14 @@ import {
sendBulkEmail,
sendBulkPush,
} from "../../api/notifications.api"
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
import {
DEFAULT_NOTIFICATION_TYPE_CONFIG,
formatNotificationTimestamp,
formatNotificationTypeLabel,
getNotificationLevelBadge,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getRoles } from "../../api/rbac.api"
import { getTeamMembers } from "../../api/team.api"
import { getUsers } from "../../api/users.api"
@ -71,70 +72,7 @@ import type { Role } from "../../types/rbac.types"
import type { TeamMember } from "../../types/team.types"
import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner"
const PAGE_SIZE = 10
const TYPE_CONFIG: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
announcement: { icon: Megaphone, color: "text-brand-600", bg: "bg-brand-100" },
system_alert: { icon: ShieldAlert, color: "text-amber-600", bg: "bg-amber-50" },
issue_created: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50" },
issue_status_updated: { icon: CheckCircle2, color: "text-sky-600", bg: "bg-sky-50" },
course_created: { icon: BookOpen, color: "text-indigo-600", bg: "bg-indigo-50" },
course_enrolled: { icon: BookOpen, color: "text-teal-600", bg: "bg-teal-50" },
sub_course_created: { icon: BookOpen, color: "text-violet-600", bg: "bg-violet-50" },
video_added: { icon: Video, color: "text-pink-600", bg: "bg-pink-50" },
user_deleted: { icon: UserPlus, color: "text-red-600", bg: "bg-red-50" },
admin_created: { icon: UserPlus, color: "text-brand-600", bg: "bg-brand-100" },
team_member_created: { icon: UserPlus, color: "text-emerald-600", bg: "bg-emerald-50" },
subscription_activated: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
payment_verified: { icon: CreditCard, color: "text-green-600", bg: "bg-green-50" },
knowledge_level_update: { icon: Info, color: "text-sky-600", bg: "bg-sky-50" },
assessment_assigned: { icon: BookOpen, color: "text-orange-600", bg: "bg-orange-50" },
}
const DEFAULT_TYPE_CONFIG = { icon: Bell, color: "text-grayScale-500", bg: "bg-grayScale-100" }
function getLevelBadge(level: string) {
switch (level) {
case "error":
case "critical":
return "destructive" as const
case "warning":
return "warning" as const
case "success":
return "success" as const
case "info":
default:
return "info" as const
}
}
function formatTimestamp(ts: string) {
const date = new Date(ts)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHr = Math.floor(diffMs / 3_600_000)
const diffDay = Math.floor(diffMs / 86_400_000)
if (diffMin < 1) return "Just now"
if (diffMin < 60) return `${diffMin}m ago`
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
})
}
function formatTypeLabel(type: string) {
return type
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ")
}
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
function digitsOnly(value: string, maxLength: number) {
return value.replace(/\D/g, "").slice(0, maxLength)
@ -149,7 +87,7 @@ function NotificationItem({
onToggleRead: (id: string, currentlyRead: boolean) => void
toggling: boolean
}) {
const config = TYPE_CONFIG[notification.type] ?? DEFAULT_TYPE_CONFIG
const config = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
return (
@ -190,7 +128,7 @@ function NotificationItem({
>
{getNotificationTitle(notification)}
</span>
<Badge variant={getLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
{notification.level}
</Badge>
</div>
@ -206,7 +144,7 @@ function NotificationItem({
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-grayScale-400">
{formatTimestamp(notification.timestamp)}
{formatNotificationTimestamp(notification.timestamp)}
</span>
<button
type="button"
@ -236,7 +174,7 @@ function NotificationItem({
{/* Meta row */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{formatTypeLabel(notification.type)}
{formatNotificationTypeLabel(notification.type)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{notification.delivery_channel}
@ -265,12 +203,16 @@ export function NotificationsPage() {
const [totalCount, setTotalCount] = useState(0)
const [globalUnread, setGlobalUnread] = useState(0)
const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [detailError, setDetailError] = useState(false)
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
@ -429,7 +371,7 @@ export function NotificationsPage() {
setError(false)
try {
const [notifRes, unreadRes] = await Promise.all([
getNotifications(PAGE_SIZE, currentOffset),
getNotifications(pageSize, currentOffset),
getUnreadCount(),
])
setNotifications(notifRes.data.notifications ?? [])
@ -440,11 +382,11 @@ export function NotificationsPage() {
} finally {
setLoading(false)
}
}, [])
}, [pageSize])
useEffect(() => {
fetchData(offset)
}, [offset, fetchData])
}, [offset, pageSize, fetchData])
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
setTogglingIds((prev) => new Set(prev).add(id))
@ -495,10 +437,10 @@ export function NotificationsPage() {
}
}, [totalCount])
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
const currentPage = Math.floor(offset / pageSize) + 1
const startEntry = totalCount === 0 ? 0 : offset + 1
const endEntry = Math.min(offset + PAGE_SIZE, totalCount)
const endEntry = Math.min(offset + pageSize, totalCount)
const getPageNumbers = () => {
const pages: (number | string)[] = []
@ -525,7 +467,7 @@ export function NotificationsPage() {
const haystack = [
getNotificationTitle(n),
getNotificationMessage(n),
formatTypeLabel(n.type),
formatNotificationTypeLabel(n.type),
n.delivery_channel,
n.level,
]
@ -537,9 +479,42 @@ export function NotificationsPage() {
return true
})
const handleOpenDetail = (notification: Notification) => {
setSelectedNotification(notification)
const loadNotificationDetail = useCallback(async (id: string) => {
setDetailLoading(true)
setDetailError(false)
setSelectedNotification(null)
setSelectedNotificationId(id)
setDetailOpen(true)
try {
const res = await getNotificationById(id)
if (!res.data) {
setDetailError(true)
toast.error("Notification not found")
return
}
setSelectedNotification(res.data)
if (!res.data.is_read) {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
)
setGlobalUnread((prev) => Math.max(0, prev - 1))
try {
await markAsRead(id)
} catch {
// list refresh on next load will reconcile
}
}
} catch {
setDetailError(true)
toast.error("Failed to load notification details")
} finally {
setDetailLoading(false)
}
}, [])
const handleOpenDetail = (notification: Notification) => {
void loadNotificationDetail(notification.id)
}
return (
@ -756,7 +731,7 @@ export function NotificationsPage() {
className="h-8 w-[150px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
{typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
@ -766,7 +741,7 @@ export function NotificationsPage() {
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<DropdownMenuRadioItem key={t} value={t}>
{formatTypeLabel(t)}
{formatNotificationTypeLabel(t)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -830,7 +805,7 @@ export function NotificationsPage() {
</TableRow>
) : (
filteredNotifications.map((n) => {
const config = TYPE_CONFIG[n.type] ?? DEFAULT_TYPE_CONFIG
const config = NOTIFICATION_TYPE_CONFIG[n.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG
const Icon = config.icon
const isToggling = togglingIds.has(n.id)
return (
@ -854,7 +829,7 @@ export function NotificationsPage() {
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-grayScale-600">
{formatTypeLabel(n.type)}
{formatNotificationTypeLabel(n.type)}
</span>
</div>
</TableCell>
@ -880,7 +855,7 @@ export function NotificationsPage() {
</TableCell>
<TableCell>
<Badge
variant={getLevelBadge(n.level)}
variant={getNotificationLevelBadge(n.level)}
className="text-[10px] uppercase tracking-wide"
>
{n.is_read ? "Read" : "Unread"}
@ -888,7 +863,7 @@ export function NotificationsPage() {
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-xs text-grayScale-400">
{formatTimestamp(n.timestamp)}
{formatNotificationTimestamp(n.timestamp)}
</span>
</TableCell>
<TableCell className="text-right">
@ -941,18 +916,25 @@ export function NotificationsPage() {
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={PAGE_SIZE}
disabled
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
<option value={PAGE_SIZE}>{PAGE_SIZE}</option>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - pageSize))}
disabled={currentPage <= 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
@ -970,7 +952,7 @@ export function NotificationsPage() {
<button
key={n}
type="button"
onClick={() => setOffset((n - 1) * PAGE_SIZE)}
onClick={() => setOffset((n - 1) * pageSize)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === currentPage
@ -983,7 +965,7 @@ export function NotificationsPage() {
),
)}
<button
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)}
onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
disabled={currentPage >= totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
@ -998,66 +980,18 @@ export function NotificationsPage() {
</>
)}
{/* Detail dialog */}
{selectedNotification && (
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
{(() => {
const Icon =
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
return <Icon className="h-4 w-4" />
})()}
</span>
<span className="truncate text-base">
{getNotificationTitle(selectedNotification)}
</span>
</DialogTitle>
<DialogDescription>
Sent via {selectedNotification.delivery_channel} ·{" "}
{formatTimestamp(selectedNotification.timestamp)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-grayScale-50 p-3">
<p className="text-sm text-grayScale-600">
{getNotificationMessage(selectedNotification)}
</p>
</div>
<div className="grid gap-3 text-xs text-grayScale-500 sm:grid-cols-2">
<div>
<p className="text-grayScale-400">Type</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{formatTypeLabel(selectedNotification.type)}
</p>
</div>
<div>
<p className="text-grayScale-400">Level</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.level}
</p>
</div>
<div>
<p className="text-grayScale-400">Channel</p>
<p className="mt-0.5 font-medium text-grayScale-700 capitalize">
{selectedNotification.delivery_channel}
</p>
</div>
<div>
<p className="text-grayScale-400">Delivery status</p>
<p className="mt-0.5 font-medium text-grayScale-700">
{selectedNotification.delivery_status}
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)}
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId)
: undefined
}
/>
{/* Bulk send dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>

View File

@ -185,10 +185,7 @@ export function EmailTemplateCreateForm({
<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>
<p className="text-xs text-grayScale-400">Your changes are saved when you submit the form.</p>
</div>
</div>
)

View File

@ -102,11 +102,7 @@ export function EmailTemplateEditForm({
<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>
.
Your changes are saved when you submit the form.
</p>
</div>

View File

@ -0,0 +1,588 @@
import { useCallback, useEffect, useState, type ReactNode } from "react"
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Copy,
CreditCard,
Eye,
RefreshCw,
TrendingUp,
Wallet,
} from "lucide-react"
import { Link } from "react-router-dom"
import { toast } from "sonner"
import { getPayments } from "../../api/payments.api"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import {
formatPaymentAmount,
formatPaymentDate,
formatPaymentMethod,
formatPaymentPlanCategory,
formatPaymentStatus,
paymentCustomerName,
paymentStatusBadgeVariant,
} from "../../lib/payments"
import type {
Payment,
PaymentPlanCategory,
PaymentProvider,
PaymentStatus,
} from "../../types/payment.types"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
const STATUS_FILTERS: { value: PaymentStatus; label: string }[] = [
{ value: "SUCCESS", label: "Success" },
{ value: "PENDING", label: "Pending" },
{ value: "FAILED", label: "Failed" },
]
const PROVIDER_FILTERS: { value: PaymentProvider; label: string }[] = [
{ value: "CHAPA", label: "Chapa" },
{ value: "ARIFPAY", label: "Arifpay" },
]
const PLAN_CATEGORY_FILTERS: { value: PaymentPlanCategory; label: string }[] = [
{ value: "LEARN_ENGLISH", label: "Learn English" },
{ value: "IELTS", label: "IELTS" },
{ value: "DUOLINGO", label: "Duolingo" },
]
type PaymentListFilters = {
status: PaymentStatus | ""
provider: PaymentProvider | ""
planCategory: PaymentPlanCategory | ""
}
function copyText(value: string, label: string) {
if (!value) return
void navigator.clipboard.writeText(value)
toast.success(`${label} copied`)
}
export function PaymentsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [payments, setPayments] = useState<Payment[]>([])
const [totalCount, setTotalCount] = useState(0)
const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(20)
const [statusFilter, setStatusFilter] = useState<PaymentStatus | "">("")
const [providerFilter, setProviderFilter] = useState<PaymentProvider | "">("")
const [planCategoryFilter, setPlanCategoryFilter] = useState<PaymentPlanCategory | "">("")
const [selected, setSelected] = useState<Payment | null>(null)
const listFilters: PaymentListFilters = {
status: statusFilter,
provider: providerFilter,
planCategory: planCategoryFilter,
}
const hasActiveFilters = Boolean(
listFilters.status || listFilters.provider || listFilters.planCategory,
)
const fetchPayments = useCallback(
async (nextOffset: number, limit: number, filters: PaymentListFilters) => {
setLoading(true)
setError(false)
try {
const res = await getPayments({
limit,
offset: nextOffset,
...(filters.status ? { status: filters.status } : {}),
...(filters.provider ? { provider: filters.provider } : {}),
...(filters.planCategory ? { plan_category: filters.planCategory } : {}),
})
setPayments(res.data.payments)
setTotalCount(res.data.total_count)
} catch (e) {
console.error(e)
setError(true)
setPayments([])
setTotalCount(0)
toast.error("Failed to load payments")
} finally {
setLoading(false)
}
},
[],
)
useEffect(() => {
void fetchPayments(offset, pageSize, listFilters)
}, [offset, pageSize, statusFilter, providerFilter, planCategoryFilter, fetchPayments])
const toggleStatus = (value: PaymentStatus) => {
setStatusFilter((current) => (current === value ? "" : value))
setOffset(0)
}
const toggleProvider = (value: PaymentProvider) => {
setProviderFilter((current) => (current === value ? "" : value))
setOffset(0)
}
const togglePlanCategory = (value: PaymentPlanCategory) => {
setPlanCategoryFilter((current) => (current === value ? "" : value))
setOffset(0)
}
const clearFilters = () => {
setStatusFilter("")
setProviderFilter("")
setPlanCategoryFilter("")
setOffset(0)
}
const successfulOnPage = payments.filter((p) => p.status.toUpperCase() === "SUCCESS")
const pageRevenue = successfulOnPage.reduce((sum, p) => sum + (Number(p.amount) || 0), 0)
const pendingOnPage = payments.filter((p) => {
const s = p.status.toUpperCase()
return s === "PENDING" || s === "PROCESSING"
}).length
const pageStart = totalCount === 0 ? 0 : offset + 1
const pageEnd = Math.min(offset + payments.length, totalCount)
const canPrev = offset > 0
const canNext = offset + pageSize < totalCount
return (
<div className="mx-auto min-w-0 w-full max-w-7xl space-y-6 px-4 py-6 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-brand-500">
Billing
</p>
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Payments</h1>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Browse and filter checkout transactions from Chapa, Arifpay, and other providers.
</p>
</div>
<Button
variant="outline"
className="shrink-0 rounded-[6px]"
disabled={loading}
onClick={() => void fetchPayments(offset, pageSize, listFilters)}
>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-brand-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-brand-50 text-brand-600">
<CreditCard className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Total transactions</p>
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-mint-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-mint-50 text-mint-600">
<TrendingUp className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Successful (this page)</p>
<p className="text-2xl font-bold text-grayScale-900">{successfulOnPage.length}</p>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-amber-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-amber-50 text-amber-700">
<Wallet className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Revenue (this page)</p>
<p className="text-2xl font-bold text-grayScale-900">
{pageRevenue.toLocaleString()} ETB
</p>
<p className="text-[11px] text-grayScale-400">{pendingOnPage} pending on page</p>
</div>
</CardContent>
</Card>
</div>
<Card className="min-w-0 overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<CardHeader className="border-b border-grayScale-50 pb-4">
<CardTitle className="text-sm font-bold text-grayScale-900">Transaction history</CardTitle>
</CardHeader>
<CardContent className="min-w-0 space-y-4 p-4 sm:p-6">
<div className="flex flex-col gap-3 rounded-[8px] border border-grayScale-100 bg-grayScale-50/40 p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Status
</span>
{STATUS_FILTERS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={statusFilter === value}
disabled={loading}
onClick={() => toggleStatus(value)}
/>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Provider
</span>
{PROVIDER_FILTERS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={providerFilter === value}
disabled={loading}
onClick={() => toggleProvider(value)}
/>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Plan
</span>
{PLAN_CATEGORY_FILTERS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={planCategoryFilter === value}
disabled={loading}
onClick={() => togglePlanCategory(value)}
/>
))}
{hasActiveFilters ? (
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-7 rounded-[6px] px-2 text-xs text-grayScale-500"
disabled={loading}
onClick={clearFilters}
>
Clear filters
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading payments</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16">
<p className="text-sm font-medium text-grayScale-700">Could not load payments</p>
<Button
variant="outline"
size="sm"
className="rounded-[6px]"
onClick={() => void fetchPayments(offset, pageSize, listFilters)}
>
Try again
</Button>
</div>
) : payments.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16 text-center">
<CreditCard className="h-10 w-10 text-grayScale-300" />
<p className="text-sm font-medium text-grayScale-700">
{hasActiveFilters ? "No payments match these filters" : "No payments yet"}
</p>
<p className="max-w-sm text-xs text-grayScale-500">
{hasActiveFilters
? "Try different filters or clear them to see more results."
: "Transactions will appear here once customers complete checkout."}
</p>
{hasActiveFilters ? (
<Button
variant="outline"
size="sm"
className="rounded-[6px]"
onClick={clearFilters}
>
Clear filters
</Button>
) : null}
</div>
) : (
<div className="min-w-0 w-full max-w-full overflow-x-auto rounded-[8px] border border-grayScale-100">
<table className="w-full min-w-[900px] text-left text-sm">
<thead>
<tr className="border-b border-grayScale-100 bg-grayScale-50/80 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Transaction</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Customer</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Plan</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Amount</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Method</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Status</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Paid</th>
<th className="sticky right-0 z-10 whitespace-nowrap bg-grayScale-50/95 px-3 py-3 text-right sm:px-4">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-grayScale-50">
{payments.map((payment) => (
<tr key={payment.id} className="group transition-colors hover:bg-grayScale-50/60">
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
<p className="font-semibold text-grayScale-900">#{payment.id}</p>
<p className="mt-0.5 max-w-[160px] truncate font-mono text-[11px] text-grayScale-500">
{payment.transaction_id || payment.session_id || "—"}
</p>
</td>
<td className="px-3 py-3 sm:px-4 sm:py-4">
<p className="font-medium text-grayScale-900">
{paymentCustomerName(payment)}
</p>
<p className="mt-0.5 truncate text-xs text-grayScale-500">
{payment.user_email || `User #${payment.user_id}`}
</p>
</td>
<td className="px-3 py-3 sm:px-4 sm:py-4">
<p className="max-w-[180px] truncate font-medium text-grayScale-800">
{payment.plan_name || `Plan #${payment.plan_id}`}
</p>
{payment.plan_category ? (
<Badge variant="secondary" className="mt-1 text-[10px]">
{formatPaymentPlanCategory(payment.plan_category)}
</Badge>
) : null}
</td>
<td className="whitespace-nowrap px-3 py-3 font-semibold text-grayScale-900 sm:px-4 sm:py-4">
{formatPaymentAmount(payment)}
</td>
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
<Badge variant="info">{formatPaymentMethod(payment.payment_method)}</Badge>
</td>
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
<Badge variant={paymentStatusBadgeVariant(payment.status)}>
{formatPaymentStatus(payment.status)}
</Badge>
</td>
<td className="whitespace-nowrap px-3 py-3 text-xs text-grayScale-600 sm:px-4 sm:py-4">
{formatPaymentDate(payment.paid_at ?? payment.created_at)}
</td>
<td className="sticky right-0 z-10 whitespace-nowrap bg-white px-3 py-3 shadow-[-8px_0_12px_-8px_rgba(0,0,0,0.08)] group-hover:bg-grayScale-50/60 sm:px-4 sm:py-4">
<div className="flex justify-end gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
aria-label="View payment details"
onClick={() => setSelected(payment)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
aria-label="Copy transaction ID"
onClick={() =>
copyText(
payment.transaction_id || payment.session_id,
"Transaction ID",
)
}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!loading && !error && totalCount > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-500">
<span>
Showing {pageStart}{pageEnd} of {totalCount}
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[6px]"
disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[6px]"
disabled={!canNext || loading}
onClick={() => setOffset((o) => o + pageSize)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
<Dialog open={selected != null} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px]">
{selected ? (
<>
<DialogHeader>
<DialogTitle>Payment #{selected.id}</DialogTitle>
<DialogDescription>
{formatPaymentStatus(selected.status)} · {formatPaymentAmount(selected)}
</DialogDescription>
</DialogHeader>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<Detail label="Customer" value={paymentCustomerName(selected)} />
<Detail label="Email" value={selected.user_email || "—"} />
<Detail
label="User"
value={
<Link
to={`/users/${selected.user_id}`}
className="font-medium text-brand-500 hover:text-brand-600"
>
View user #{selected.user_id}
</Link>
}
/>
<Detail label="Plan" value={selected.plan_name || `Plan #${selected.plan_id}`} />
<Detail
label="Category"
value={formatPaymentPlanCategory(selected.plan_category)}
/>
<Detail label="Method" value={formatPaymentMethod(selected.payment_method)} />
<Detail label="Status" value={formatPaymentStatus(selected.status)} />
<Detail label="Transaction ID" value={selected.transaction_id || "—"} mono />
<Detail label="Session ID" value={selected.session_id || "—"} mono />
<Detail label="Subscription" value={`#${selected.subscription_id}`} />
<Detail label="Paid at" value={formatPaymentDate(selected.paid_at)} />
<Detail label="Expires at" value={formatPaymentDate(selected.expires_at)} />
<Detail label="Created" value={formatPaymentDate(selected.created_at)} />
<Detail label="Updated" value={formatPaymentDate(selected.updated_at)} />
</dl>
{selected.payment_url ? (
<a
href={selected.payment_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex text-sm font-semibold text-brand-500 hover:text-brand-600"
>
Open checkout URL
</a>
) : null}
</>
) : null}
</DialogContent>
</Dialog>
</div>
)
}
function FilterChip({
label,
active,
disabled,
onClick,
}: {
label: string
active: boolean
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"rounded-[6px] border px-2.5 py-1 text-xs font-semibold transition-colors",
active
? "border-brand-500 bg-brand-500 text-white"
: "border-grayScale-200 bg-white text-grayScale-600 hover:border-brand-200 hover:text-brand-600",
disabled && "pointer-events-none opacity-50",
)}
>
{label}
</button>
)
}
function Detail({
label,
value,
mono,
}: {
label: string
value: ReactNode
mono?: boolean
}) {
return (
<div className="rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-3 py-2">
<dt className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">{label}</dt>
<dd
className={cn(
"mt-0.5 font-medium text-grayScale-800 break-all",
mono && "font-mono text-xs",
)}
>
{value}
</dd>
</div>
)
}

View File

@ -5,6 +5,7 @@ import {
Search,
Shield,
ShieldCheck,
ChevronDown,
ChevronLeft,
ChevronRight,
AlertCircle,
@ -37,6 +38,7 @@ import {
} from "../../api/rbac.api"
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { toast } from "sonner"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { teamRoleFromRbacRole } from "../../lib/teamRoles"
@ -49,7 +51,7 @@ export function RolesListPage() {
const [roles, setRoles] = useState<Role[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [query, setQuery] = useState("")
const [debouncedQuery, setDebouncedQuery] = useState("")
const [loading, setLoading] = useState(true)
@ -543,34 +545,59 @@ export function RolesListPage() {
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
<p className="text-xs text-grayScale-400">
Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, total)} of {total} roles
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-xs font-medium text-grayScale-600">
{page} / {totalPages}
{total > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, total)} of {total} roles
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setPage(1)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-xs font-medium text-grayScale-600">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
)}
</>

View File

@ -153,12 +153,8 @@ export function InviteTeamMemberDialog({
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>
Send one invitation per email address. Invitees complete account setup using the link in
their email
{roleLocked ? (
<>
{" "}

View File

@ -0,0 +1,521 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import {
AlertTriangle,
Apple,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
ExternalLink,
Pencil,
Plus,
RefreshCw,
Search,
Smartphone,
TabletSmartphone,
Trash2,
} from "lucide-react"
import { toast } from "sonner"
import { getAppVersions } from "../../api/app-versions.api"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import {
formatAppPlatform,
formatAppVersionCreatedAt,
formatUpdateType,
formatVersionStatus,
versionLabel,
} from "../../lib/appVersions"
import type { AppPlatform, AppVersion, AppVersionStatus } from "../../types/app-version.types"
import { CreateAppVersionDialog } from "./components/CreateAppVersionDialog"
import { DeleteAppVersionDialog } from "./components/DeleteAppVersionDialog"
import { EditAppVersionDialog } from "./components/EditAppVersionDialog"
function PlatformIcon({ platform }: { platform: string }) {
const upper = platform.toUpperCase()
if (upper === "IOS") {
return <Apple className="h-4 w-4" />
}
return <Smartphone className="h-4 w-4" />
}
function updateTypeBadgeVariant(updateType: string): "destructive" | "warning" | "info" | "secondary" {
const t = updateType.toUpperCase()
if (t === "FORCE") return "destructive"
if (t === "SOFT") return "warning"
return "info"
}
function statusBadgeVariant(status: string): "success" | "secondary" | "warning" {
const s = status.toUpperCase()
if (s === "ACTIVE") return "success"
if (s === "DRAFT") return "warning"
return "secondary"
}
export function AppVersionsTab() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [versions, setVersions] = useState<AppVersion[]>([])
const [totalCount, setTotalCount] = useState(0)
const [offset, setOffset] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [query, setQuery] = useState("")
const [platformFilter, setPlatformFilter] = useState<"all" | AppPlatform>("all")
const [statusFilter, setStatusFilter] = useState<"all" | AppVersionStatus>("all")
const [createOpen, setCreateOpen] = useState(false)
const [versionToEdit, setVersionToEdit] = useState<AppVersion | null>(null)
const [versionToDelete, setVersionToDelete] = useState<AppVersion | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(false)
try {
const res = await getAppVersions({ limit: pageSize, offset })
setVersions(res.data.versions)
setTotalCount(res.data.total_count)
} catch (e) {
console.error(e)
setError(true)
setVersions([])
setTotalCount(0)
toast.error("Failed to load app versions")
} finally {
setLoading(false)
}
}, [offset, pageSize])
useEffect(() => {
void load()
}, [load])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return [...versions]
.filter((v) => {
if (platformFilter !== "all" && v.platform !== platformFilter) return false
if (statusFilter !== "all" && v.status !== statusFilter) return false
if (!q) return true
const haystack = [
v.version_name,
String(v.version_code),
v.platform,
v.update_type,
v.release_notes,
v.status,
]
.join(" ")
.toLowerCase()
return haystack.includes(q)
})
.sort((a, b) => b.version_code - a.version_code)
}, [versions, query, platformFilter, statusFilter])
const androidCount = versions.filter((v) => v.platform.toUpperCase() === "ANDROID").length
const iosCount = versions.filter((v) => v.platform.toUpperCase() === "IOS").length
const activeCount = versions.filter((v) => v.status.toUpperCase() === "ACTIVE").length
const forceCount = versions.filter((v) => v.update_type.toUpperCase() === "FORCE").length
const pageStart = totalCount === 0 ? 0 : offset + 1
const pageEnd = Math.min(offset + versions.length, totalCount)
const canPrev = offset > 0
const canNext = offset + pageSize < totalCount
const handleCreated = (version: AppVersion) => {
if (offset === 0) {
setVersions((prev) => {
const without = prev.filter((v) => v.id !== version.id)
return [version, ...without]
})
setTotalCount((c) => c + 1)
} else {
setOffset(0)
}
}
const handleUpdated = (version: AppVersion) => {
setVersions((prev) => prev.map((v) => (v.id === version.id ? version : v)))
}
const handleDeleted = (id: number) => {
setVersions((prev) => prev.filter((v) => v.id !== id))
setTotalCount((c) => Math.max(0, c - 1))
}
return (
<div className="animate-in fade-in slide-in-from-bottom-2 min-w-0 w-full max-w-full space-y-6 duration-300">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-brand-500">
Mobile releases
</p>
<h2 className="text-lg font-bold text-grayScale-900">App version control</h2>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Manage Android and iOS release metadata for in-app update prompts.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="shrink-0 rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
onClick={() => setCreateOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
New version
</Button>
<Button
variant="outline"
className="shrink-0 rounded-[6px]"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-brand-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-brand-50 text-brand-600">
<TabletSmartphone className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Total versions</p>
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-mint-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-mint-50 text-mint-600">
<Smartphone className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Android · iOS</p>
<p className="text-2xl font-bold text-grayScale-900">
{androidCount} · {iosCount}
</p>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-sky-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-sky-50 text-sky-600">
<Calendar className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Active on page</p>
<p className="text-2xl font-bold text-grayScale-900">{activeCount}</p>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-destructive" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-red-50 text-destructive">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Force updates</p>
<p className="text-2xl font-bold text-grayScale-900">{forceCount}</p>
</div>
</CardContent>
</Card>
</div>
<Card className="min-w-0 rounded-[8px] border border-grayScale-100 shadow-none">
<CardHeader className="border-b border-grayScale-50 pb-4">
<CardTitle className="text-sm font-bold text-grayScale-900">Release history</CardTitle>
</CardHeader>
<CardContent className="min-w-0 space-y-4 p-4 sm:p-6">
<div className="flex min-w-0 flex-col gap-4">
<div className="relative w-full min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
className="w-full rounded-[6px] pl-9"
placeholder="Search version, notes, platform…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="flex min-w-0 flex-wrap gap-2">
{(
[
{ id: "all", label: "All platforms" },
{ id: "ANDROID", label: "Android" },
{ id: "IOS", label: "iOS" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setPlatformFilter(tab.id)}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-semibold transition-colors",
platformFilter === tab.id
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-600 hover:bg-grayScale-200",
)}
>
{tab.label}
</button>
))}
</div>
<div className="flex min-w-0 flex-wrap gap-2">
{(
[
{ id: "all", label: "All statuses" },
{ id: "ACTIVE", label: "Active" },
{ id: "INACTIVE", label: "Inactive" },
{ id: "DRAFT", label: "Draft" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setStatusFilter(tab.id)}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-semibold transition-colors",
statusFilter === tab.id
? "bg-grayScale-800 text-white"
: "bg-grayScale-100 text-grayScale-600 hover:bg-grayScale-200",
)}
>
{tab.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading app versions</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16">
<p className="text-sm font-medium text-grayScale-700">Could not load versions</p>
<Button variant="outline" size="sm" className="rounded-[6px]" onClick={() => void load()}>
Try again
</Button>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16 text-center">
<TabletSmartphone className="h-10 w-10 text-grayScale-300" />
<p className="text-sm font-medium text-grayScale-700">
{versions.length === 0 ? "No app versions yet" : "No versions match your filters"}
</p>
<p className="max-w-sm text-xs text-grayScale-500">
{versions.length === 0
? "Publish your first Android or iOS release to control learner update prompts."
: "Try a different search or filter."}
</p>
{versions.length === 0 ? (
<Button
className="mt-1 rounded-[6px] bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setCreateOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Publish version
</Button>
) : null}
</div>
) : (
<div className="min-w-0 w-full max-w-full overflow-x-auto rounded-[8px] border border-grayScale-100">
<table className="w-full min-w-[640px] text-left text-sm">
<thead>
<tr className="border-b border-grayScale-100 bg-grayScale-50/80 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Release</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Platform</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Update</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Min</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Status</th>
<th className="min-w-[140px] px-3 py-3 sm:px-4">Notes</th>
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Published</th>
<th className="sticky right-0 z-10 whitespace-nowrap bg-grayScale-50/95 px-3 py-3 text-right shadow-[-8px_0_12px_-8px_rgba(0,0,0,0.12)] backdrop-blur-sm sm:px-4">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-grayScale-50">
{filtered.map((version) => (
<tr key={version.id} className="group transition-colors hover:bg-grayScale-50/60">
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
<p className="font-semibold text-grayScale-900">
{versionLabel(version)}
</p>
<p className="mt-0.5 text-xs text-grayScale-400">ID {version.id}</p>
</td>
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
<Badge
variant="secondary"
className="inline-flex items-center gap-1.5 font-medium"
>
<PlatformIcon platform={version.platform} />
{formatAppPlatform(version.platform)}
</Badge>
</td>
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
<Badge variant={updateTypeBadgeVariant(version.update_type)}>
{formatUpdateType(version.update_type)}
</Badge>
</td>
<td className="whitespace-nowrap px-3 py-3 font-mono text-xs text-grayScale-700 sm:px-4 sm:py-4">
{version.min_supported_version_code}
</td>
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
<Badge variant={statusBadgeVariant(version.status)}>
{formatVersionStatus(version.status)}
</Badge>
</td>
<td className="max-w-[180px] px-3 py-3 sm:px-4 sm:py-4">
<p className="line-clamp-2 text-xs text-grayScale-600">
{version.release_notes}
</p>
</td>
<td className="whitespace-nowrap px-3 py-3 text-grayScale-500 sm:px-4 sm:py-4">
<span className="inline-flex items-center gap-1.5 text-xs">
<Calendar className="h-3.5 w-3.5 shrink-0" />
{formatAppVersionCreatedAt(version.created_at)}
</span>
</td>
<td className="sticky right-0 z-10 whitespace-nowrap bg-white px-3 py-3 shadow-[-8px_0_12px_-8px_rgba(0,0,0,0.08)] group-hover:bg-grayScale-50/60 sm:px-4 sm:py-4">
<div className="flex justify-end gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
asChild
>
<a
href={version.store_url}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open store for ${versionLabel(version)}`}
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
aria-label={`Edit ${versionLabel(version)}`}
onClick={() => setVersionToEdit(version)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-destructive"
aria-label={`Delete ${versionLabel(version)}`}
onClick={() => setVersionToDelete(version)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!loading && !error && totalCount > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
<div className="flex flex-wrap items-center gap-2">
<span>
Showing {pageStart}{pageEnd} of {totalCount}
</span>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Rows per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[6px]"
disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[6px]"
disabled={!canNext || loading}
onClick={() => setOffset((o) => o + pageSize)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
<CreateAppVersionDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleCreated}
/>
<EditAppVersionDialog
version={versionToEdit}
open={versionToEdit != null}
onOpenChange={(open) => {
if (!open) setVersionToEdit(null)
}}
onUpdated={handleUpdated}
/>
<DeleteAppVersionDialog
version={versionToDelete}
open={versionToDelete != null}
onOpenChange={(open) => {
if (!open) setVersionToDelete(null)
}}
onDeleted={handleDeleted}
/>
</div>
)
}

View File

@ -0,0 +1,342 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import {
Calendar,
CreditCard,
Package,
Pencil,
Plus,
RefreshCw,
Search,
Tag,
Trash2,
} from "lucide-react"
import { toast } from "sonner"
import { getSubscriptionPlans } from "../../api/subscription-plans.api"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import {
formatPlanCategory,
formatPlanCreatedAt,
formatPlanDuration,
formatPlanPrice,
} from "../../lib/subscriptionPlans"
import type { SubscriptionPlan } from "../../types/subscription.types"
import { CreateSubscriptionPlanDialog } from "./components/CreateSubscriptionPlanDialog"
import { DeleteSubscriptionPlanDialog } from "./components/DeleteSubscriptionPlanDialog"
import { EditSubscriptionPlanDialog } from "./components/EditSubscriptionPlanDialog"
export function SubscriptionPlansTab() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
const [query, setQuery] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all")
const [createOpen, setCreateOpen] = useState(false)
const [planToEdit, setPlanToEdit] = useState<SubscriptionPlan | null>(null)
const [planToDelete, setPlanToDelete] = useState<SubscriptionPlan | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(false)
try {
const res = await getSubscriptionPlans()
setPlans(res.data)
} catch (e) {
console.error(e)
setError(true)
setPlans([])
toast.error("Failed to load subscription packages")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return [...plans]
.filter((plan) => {
if (statusFilter === "active" && !plan.is_active) return false
if (statusFilter === "inactive" && plan.is_active) return false
if (!q) return true
const haystack = [plan.name, plan.description, plan.category, plan.currency]
.join(" ")
.toLowerCase()
return haystack.includes(q)
})
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
}, [plans, query, statusFilter])
const activeCount = plans.filter((p) => p.is_active).length
const handleCreated = (plan: SubscriptionPlan) => {
setPlans((prev) => {
const without = prev.filter((p) => p.id !== plan.id)
return [plan, ...without]
})
}
const handleUpdated = (plan: SubscriptionPlan) => {
setPlans((prev) => prev.map((p) => (p.id === plan.id ? plan : p)))
}
const handleDeleted = (id: number) => {
setPlans((prev) => prev.filter((p) => p.id !== id))
}
return (
<div className="animate-in fade-in slide-in-from-bottom-2 min-w-0 w-full max-w-full space-y-6 duration-300">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-brand-500">
Billing & catalog
</p>
<h2 className="text-lg font-bold text-grayScale-900">Subscription packages</h2>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
Manage learner subscription plans. Create, edit, or remove packages for the learner
checkout flow.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="shrink-0 rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
onClick={() => setCreateOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
New package
</Button>
<Button
variant="outline"
className="shrink-0 rounded-[6px]"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-brand-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-brand-50 text-brand-600">
<Package className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Total packages</p>
<p className="text-2xl font-bold text-grayScale-900">{plans.length}</p>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-mint-500" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-mint-50 text-mint-600">
<CreditCard className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Active</p>
<p className="text-2xl font-bold text-grayScale-900">{activeCount}</p>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
<div className="h-1 bg-grayScale-300" />
<CardContent className="flex items-center gap-4 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-grayScale-100 text-grayScale-500">
<Tag className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium text-grayScale-500">Inactive</p>
<p className="text-2xl font-bold text-grayScale-900">{plans.length - activeCount}</p>
</div>
</CardContent>
</Card>
</div>
<Card className="min-w-0 rounded-[8px] border border-grayScale-100 shadow-none">
<CardHeader className="border-b border-grayScale-50 pb-4">
<CardTitle className="text-sm font-bold text-grayScale-900">All packages</CardTitle>
</CardHeader>
<CardContent className="min-w-0 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="rounded-[6px] pl-9"
placeholder="Search by name, description, or category…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2">
{(
[
{ id: "all", label: "All" },
{ id: "active", label: "Active" },
{ id: "inactive", label: "Inactive" },
] as const
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setStatusFilter(tab.id)}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-semibold transition-colors",
statusFilter === tab.id
? "bg-brand-500 text-white"
: "bg-grayScale-100 text-grayScale-600 hover:bg-grayScale-200",
)}
>
{tab.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading packages</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16">
<p className="text-sm font-medium text-grayScale-700">Could not load packages</p>
<Button variant="outline" size="sm" className="rounded-[6px]" onClick={() => void load()}>
Try again
</Button>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16 text-center">
<Package className="h-10 w-10 text-grayScale-300" />
<p className="text-sm font-medium text-grayScale-700">
{plans.length === 0 ? "No subscription packages yet" : "No packages match your filters"}
</p>
<p className="max-w-sm text-xs text-grayScale-500">
{plans.length === 0
? "Create your first package to offer paid access in the learner app."
: "Try a different search or status filter."}
</p>
{plans.length === 0 ? (
<Button
className="mt-1 rounded-[6px] bg-brand-500 text-white hover:bg-brand-600"
onClick={() => setCreateOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Create package
</Button>
) : null}
</div>
) : (
<div className="min-w-0 w-full max-w-full overflow-x-auto rounded-[8px] border border-grayScale-100">
<table className="w-full min-w-[800px] text-left text-sm">
<thead>
<tr className="border-b border-grayScale-100 bg-grayScale-50/80 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
<th className="px-4 py-3">Package</th>
<th className="px-4 py-3">Category</th>
<th className="px-4 py-3">Duration</th>
<th className="px-4 py-3">Price</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-grayScale-50">
{filtered.map((plan) => (
<tr key={plan.id} className="transition-colors hover:bg-grayScale-50/60">
<td className="px-4 py-4">
<p className="font-semibold text-grayScale-900">{plan.name}</p>
<p className="mt-0.5 line-clamp-2 max-w-xs text-xs text-grayScale-500">
{plan.description}
</p>
</td>
<td className="px-4 py-4">
<Badge variant="secondary" className="font-medium">
{formatPlanCategory(plan.category)}
</Badge>
</td>
<td className="px-4 py-4 text-grayScale-700">
{formatPlanDuration(plan)}
</td>
<td className="px-4 py-4 font-semibold text-grayScale-900">
{formatPlanPrice(plan)}
</td>
<td className="px-4 py-4">
<Badge variant={plan.is_active ? "success" : "secondary"}>
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</td>
<td className="px-4 py-4 text-grayScale-500">
<span className="inline-flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 shrink-0" />
{formatPlanCreatedAt(plan.created_at)}
</span>
</td>
<td className="px-4 py-4">
<div className="flex justify-end gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
aria-label={`Edit ${plan.name}`}
onClick={() => setPlanToEdit(plan)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-destructive"
aria-label={`Delete ${plan.name}`}
onClick={() => setPlanToDelete(plan)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<CreateSubscriptionPlanDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleCreated}
/>
<EditSubscriptionPlanDialog
plan={planToEdit}
open={planToEdit != null}
onOpenChange={(open) => {
if (!open) setPlanToEdit(null)
}}
onUpdated={handleUpdated}
/>
<DeleteSubscriptionPlanDialog
plan={planToDelete}
open={planToDelete != null}
onOpenChange={(open) => {
if (!open) setPlanToDelete(null)
}}
onDeleted={handleDeleted}
/>
</div>
)
}

View File

@ -0,0 +1,320 @@
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { toast } from "sonner"
import { createAppVersion } from "../../../api/app-versions.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 { SpinnerIcon } from "../../../components/ui/spinner-icon"
import { Textarea } from "../../../components/ui/textarea"
import {
APP_PLATFORMS,
APP_UPDATE_TYPES,
APP_VERSION_STATUSES,
DEFAULT_STORE_URLS,
} from "../../../lib/appVersions"
import type {
AppPlatform,
AppUpdateType,
AppVersion,
AppVersionStatus,
CreateAppVersionPayload,
} from "../../../types/app-version.types"
export interface CreateAppVersionDraft {
platform: AppPlatform
version_name: string
version_code: string
update_type: AppUpdateType
release_notes: string
store_url: string
min_supported_version_code: string
status: AppVersionStatus
}
export const EMPTY_APP_VERSION_DRAFT: CreateAppVersionDraft = {
platform: "ANDROID",
version_name: "",
version_code: "",
update_type: "FORCE",
release_notes: "",
store_url: DEFAULT_STORE_URLS.ANDROID,
min_supported_version_code: "",
status: "ACTIVE",
}
function draftToPayload(draft: CreateAppVersionDraft): CreateAppVersionPayload | null {
const version_name = draft.version_name.trim()
const version_code = Number(draft.version_code)
const min_supported_version_code = Number(draft.min_supported_version_code)
const release_notes = draft.release_notes.trim()
const store_url = draft.store_url.trim()
if (!version_name) return null
if (!Number.isFinite(version_code) || version_code < 1) return null
if (!Number.isFinite(min_supported_version_code) || min_supported_version_code < 0) return null
if (!release_notes) return null
if (!store_url) return null
try {
new URL(store_url)
} catch {
return null
}
return {
platform: draft.platform,
version_name,
version_code,
update_type: draft.update_type,
release_notes,
store_url,
min_supported_version_code,
status: draft.status,
}
}
type CreateAppVersionDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (version: AppVersion) => void
}
export function CreateAppVersionDialog({
open,
onOpenChange,
onCreated,
}: CreateAppVersionDialogProps) {
const [draft, setDraft] = useState<CreateAppVersionDraft>(EMPTY_APP_VERSION_DRAFT)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
setDraft(EMPTY_APP_VERSION_DRAFT)
setSaving(false)
}
}, [open])
const handlePlatformChange = (platform: AppPlatform) => {
setDraft((d) => ({
...d,
platform,
store_url:
d.store_url === DEFAULT_STORE_URLS.ANDROID || d.store_url === DEFAULT_STORE_URLS.IOS
? DEFAULT_STORE_URLS[platform] ?? d.store_url
: d.store_url,
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const payload = draftToPayload(draft)
if (!payload) {
toast.error("Please fill in all required fields with valid values.")
return
}
setSaving(true)
try {
const res = await createAppVersion(payload)
if (!res.data) {
toast.error("Version was created but the response could not be read.")
return
}
toast.success(res.message || "App version created successfully")
onCreated(res.data)
onOpenChange(false)
} catch {
toast.error("Failed to create app version.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
<form onSubmit={handleSubmit}>
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
New app version
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Publish a new app release. Learners on older builds will see update prompts based on
these rules.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-6 py-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Platform <span className="text-destructive">*</span>
</label>
<Select
value={draft.platform}
onChange={(e) => handlePlatformChange(e.target.value as AppPlatform)}
className="rounded-[6px]"
>
{APP_PLATFORMS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Status
</label>
<Select
value={draft.status}
onChange={(e) =>
setDraft((d) => ({ ...d, status: e.target.value as AppVersionStatus }))
}
className="rounded-[6px]"
>
{APP_VERSION_STATUSES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Version name <span className="text-destructive">*</span>
</label>
<Input
value={draft.version_name}
onChange={(e) => setDraft((d) => ({ ...d, version_name: e.target.value }))}
placeholder="1.3.0"
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Version code <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={1}
step={1}
value={draft.version_code}
onChange={(e) => setDraft((d) => ({ ...d, version_code: e.target.value }))}
placeholder="15"
className="rounded-[6px]"
required
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Update type
</label>
<Select
value={draft.update_type}
onChange={(e) =>
setDraft((d) => ({ ...d, update_type: e.target.value as AppUpdateType }))
}
className="rounded-[6px]"
>
{APP_UPDATE_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Min supported code <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={0}
step={1}
value={draft.min_supported_version_code}
onChange={(e) =>
setDraft((d) => ({ ...d, min_supported_version_code: e.target.value }))
}
placeholder="12"
className="rounded-[6px]"
required
/>
<p className="text-[11px] text-grayScale-500">
Builds below this code are prompted to update
</p>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Store URL <span className="text-destructive">*</span>
</label>
<Input
type="url"
value={draft.store_url}
onChange={(e) => setDraft((d) => ({ ...d, store_url: e.target.value }))}
placeholder="https://play.google.com/store/apps/details?id=…"
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Release notes <span className="text-destructive">*</span>
</label>
<Textarea
value={draft.release_notes}
onChange={(e) => setDraft((d) => ({ ...d, release_notes: e.target.value }))}
placeholder="Critical security update and performance improvements."
className="min-h-[96px] rounded-[6px]"
required
/>
</div>
</div>
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
className="rounded-[6px]"
disabled={saving}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={saving}
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
>
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{saving ? "Publishing…" : "Publish version"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,307 @@
import { useEffect, useState } from "react"
import { Plus } from "lucide-react"
import { toast } from "sonner"
import { createSubscriptionPlan } from "../../../api/subscription-plans.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 { SpinnerIcon } from "../../../components/ui/spinner-icon"
import { Textarea } from "../../../components/ui/textarea"
import { cn } from "../../../lib/utils"
import {
SUBSCRIPTION_CURRENCIES,
SUBSCRIPTION_DURATION_UNITS,
SUBSCRIPTION_PLAN_CATEGORIES,
} from "../../../lib/subscriptionPlans"
import type {
CreateSubscriptionPlanPayload,
SubscriptionPlan,
SubscriptionPlanCategory,
SubscriptionPlanDurationUnit,
} from "../../../types/subscription.types"
export interface CreateSubscriptionPlanDraft {
name: string
description: string
category: SubscriptionPlanCategory
duration_value: string
duration_unit: SubscriptionPlanDurationUnit
price: string
currency: string
is_active: boolean
}
export const EMPTY_SUBSCRIPTION_PLAN_DRAFT: CreateSubscriptionPlanDraft = {
name: "",
description: "",
category: "LEARN_ENGLISH",
duration_value: "1",
duration_unit: "MONTH",
price: "",
currency: "ETB",
is_active: true,
}
function draftToPayload(draft: CreateSubscriptionPlanDraft): CreateSubscriptionPlanPayload | null {
const name = draft.name.trim()
const description = draft.description.trim()
const duration_value = Number(draft.duration_value)
const price = Number(draft.price)
if (!name) return null
if (!description) return null
if (!Number.isFinite(duration_value) || duration_value < 1) return null
if (!Number.isFinite(price) || price < 0) return null
return {
name,
description,
category: draft.category,
duration_value,
duration_unit: draft.duration_unit,
price,
currency: draft.currency,
is_active: draft.is_active,
}
}
type CreateSubscriptionPlanDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (plan: SubscriptionPlan) => void
}
export function CreateSubscriptionPlanDialog({
open,
onOpenChange,
onCreated,
}: CreateSubscriptionPlanDialogProps) {
const [draft, setDraft] = useState<CreateSubscriptionPlanDraft>(EMPTY_SUBSCRIPTION_PLAN_DRAFT)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
setDraft(EMPTY_SUBSCRIPTION_PLAN_DRAFT)
setSaving(false)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const payload = draftToPayload(draft)
if (!payload) {
toast.error("Please fill in all required fields with valid values.")
return
}
setSaving(true)
try {
const res = await createSubscriptionPlan(payload)
if (!res.data) {
toast.error("Plan was created but the response could not be read.")
return
}
toast.success(res.message || "Subscription plan created successfully")
onCreated(res.data)
onOpenChange(false)
} catch {
toast.error("Failed to create subscription plan.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
<form onSubmit={handleSubmit}>
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
New subscription package
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Add a new subscription package for learners.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-6 py-5">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Package name <span className="text-destructive">*</span>
</label>
<Input
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="e.g. Monthly Premium"
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Description <span className="text-destructive">*</span>
</label>
<Textarea
value={draft.description}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
placeholder="What learners get with this package"
className="min-h-[88px] rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Category
</label>
<Select
value={draft.category}
onChange={(e) =>
setDraft((d) => ({ ...d, category: e.target.value as SubscriptionPlanCategory }))
}
className="rounded-[6px]"
>
{SUBSCRIPTION_PLAN_CATEGORIES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Duration <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={1}
step={1}
value={draft.duration_value}
onChange={(e) => setDraft((d) => ({ ...d, duration_value: e.target.value }))}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Duration unit
</label>
<Select
value={draft.duration_unit}
onChange={(e) =>
setDraft((d) => ({
...d,
duration_unit: e.target.value as SubscriptionPlanDurationUnit,
}))
}
className="rounded-[6px]"
>
{SUBSCRIPTION_DURATION_UNITS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Price <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={0}
step="0.01"
value={draft.price}
onChange={(e) => setDraft((d) => ({ ...d, price: e.target.value }))}
placeholder="5.00"
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Currency
</label>
<Select
value={draft.currency}
onChange={(e) => setDraft((d) => ({ ...d, currency: e.target.value }))}
className="rounded-[6px]"
>
{SUBSCRIPTION_CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
</div>
</div>
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
<div>
<p className="text-sm font-medium text-grayScale-800">Active package</p>
<p className="text-xs text-grayScale-500">
Inactive plans stay in the catalog but are hidden from checkout
</p>
</div>
<button
type="button"
role="switch"
aria-checked={draft.is_active}
onClick={() => setDraft((d) => ({ ...d, is_active: !d.is_active }))}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
draft.is_active ? "bg-brand-500" : "bg-grayScale-200",
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
draft.is_active ? "translate-x-5" : "translate-x-0.5",
)}
/>
</button>
</label>
</div>
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
className="rounded-[6px]"
disabled={saving}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={saving}
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
>
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{saving ? "Creating…" : "Create package"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,98 @@
import { useState } from "react"
import { Trash2 } from "lucide-react"
import { toast } from "sonner"
import { deleteAppVersion } from "../../../api/app-versions.api"
import { Button } from "../../../components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog"
import { formatAppPlatform, versionLabel } from "../../../lib/appVersions"
import type { AppVersion } from "../../../types/app-version.types"
type DeleteAppVersionDialogProps = {
version: AppVersion | null
open: boolean
onOpenChange: (open: boolean) => void
onDeleted: (id: number) => void
}
export function DeleteAppVersionDialog({
version,
open,
onOpenChange,
onDeleted,
}: DeleteAppVersionDialogProps) {
const [deleting, setDeleting] = useState(false)
const handleOpenChange = (next: boolean) => {
if (!next && !deleting) onOpenChange(false)
}
const handleConfirm = async () => {
if (!version) return
setDeleting(true)
try {
const res = await deleteAppVersion(version.id)
toast.success(res.message || "App version deleted successfully")
onDeleted(version.id)
onOpenChange(false)
} catch (e: unknown) {
console.error(e)
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Failed to delete app version"
toast.error(msg)
} finally {
setDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md rounded-[12px] border border-grayScale-100 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 app version?
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
This permanently removes the release record. Learners will no longer receive update
prompts for this version entry. This action cannot be undone.
</DialogDescription>
</DialogHeader>
{version ? (
<div className="space-y-1 rounded-[8px] border border-grayScale-100 bg-grayScale-50 px-4 py-3">
<p className="text-sm font-semibold text-grayScale-900">{versionLabel(version)}</p>
<p className="text-xs text-grayScale-500">
#{version.id} · {formatAppPlatform(version.platform)}
</p>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-2">
<Button
variant="outline"
className="rounded-[6px]"
disabled={deleting}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
variant="destructive"
className="rounded-[6px]"
disabled={deleting || !version}
onClick={() => void handleConfirm()}
>
{deleting ? "Deleting…" : "Delete version"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,98 @@
import { useState } from "react"
import { Trash2 } from "lucide-react"
import { toast } from "sonner"
import { deleteSubscriptionPlan } from "../../../api/subscription-plans.api"
import { Button } from "../../../components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog"
import { formatPlanPrice } from "../../../lib/subscriptionPlans"
import type { SubscriptionPlan } from "../../../types/subscription.types"
type DeleteSubscriptionPlanDialogProps = {
plan: SubscriptionPlan | null
open: boolean
onOpenChange: (open: boolean) => void
onDeleted: (id: number) => void
}
export function DeleteSubscriptionPlanDialog({
plan,
open,
onOpenChange,
onDeleted,
}: DeleteSubscriptionPlanDialogProps) {
const [deleting, setDeleting] = useState(false)
const handleOpenChange = (next: boolean) => {
if (!next && !deleting) onOpenChange(false)
}
const handleConfirm = async () => {
if (!plan) return
setDeleting(true)
try {
const res = await deleteSubscriptionPlan(plan.id)
toast.success(res.message || "Subscription plan deleted successfully")
onDeleted(plan.id)
onOpenChange(false)
} catch (e: unknown) {
console.error(e)
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Failed to delete subscription plan"
toast.error(msg)
} finally {
setDeleting(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md rounded-[12px] border border-grayScale-100 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 subscription package?
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
This permanently removes the package from the catalog. Learners will no longer be
able to purchase it. This action cannot be undone.
</DialogDescription>
</DialogHeader>
{plan ? (
<div className="space-y-1 rounded-[8px] border border-grayScale-100 bg-grayScale-50 px-4 py-3">
<p className="text-sm font-semibold text-grayScale-900">{plan.name}</p>
<p className="text-xs text-grayScale-500">
#{plan.id} · {formatPlanPrice(plan)}
</p>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-2">
<Button
variant="outline"
className="rounded-[6px]"
disabled={deleting}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
variant="destructive"
className="rounded-[6px]"
disabled={deleting || !plan}
onClick={() => void handleConfirm()}
>
{deleting ? "Deleting…" : "Delete package"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,274 @@
import { useEffect, useState } from "react"
import { Save } from "lucide-react"
import { toast } from "sonner"
import { updateAppVersion } from "../../../api/app-versions.api"
import { Badge } from "../../../components/ui/badge"
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 { SpinnerIcon } from "../../../components/ui/spinner-icon"
import { Textarea } from "../../../components/ui/textarea"
import {
APP_UPDATE_TYPES,
APP_VERSION_STATUSES,
formatAppPlatform,
versionLabel,
} from "../../../lib/appVersions"
import type {
AppUpdateType,
AppVersion,
AppVersionStatus,
UpdateAppVersionPayload,
} from "../../../types/app-version.types"
interface EditDraft {
update_type: AppUpdateType
release_notes: string
store_url: string
min_supported_version_code: string
status: AppVersionStatus
}
function versionToDraft(version: AppVersion): EditDraft {
return {
update_type: version.update_type,
release_notes: version.release_notes,
store_url: version.store_url,
min_supported_version_code: String(version.min_supported_version_code),
status: version.status,
}
}
function draftToPayload(draft: EditDraft): UpdateAppVersionPayload | null {
const release_notes = draft.release_notes.trim()
const store_url = draft.store_url.trim()
const min_supported_version_code = Number(draft.min_supported_version_code)
if (!release_notes) return null
if (!store_url) return null
if (!Number.isFinite(min_supported_version_code) || min_supported_version_code < 0) return null
try {
new URL(store_url)
} catch {
return null
}
return {
update_type: draft.update_type,
release_notes,
store_url,
min_supported_version_code,
status: draft.status,
}
}
type EditAppVersionDialogProps = {
version: AppVersion | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: (version: AppVersion) => void
}
export function EditAppVersionDialog({
version,
open,
onOpenChange,
onUpdated,
}: EditAppVersionDialogProps) {
const [draft, setDraft] = useState<EditDraft | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open && version) {
setDraft(versionToDraft(version))
setSaving(false)
}
if (!open) {
setDraft(null)
setSaving(false)
}
}, [open, version])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!version || !draft) return
const payload = draftToPayload(draft)
if (!payload) {
toast.error("Please fill in all required fields with valid values.")
return
}
setSaving(true)
try {
const res = await updateAppVersion(version.id, payload)
if (!res.data) {
toast.error("Version was updated but the response could not be read.")
return
}
toast.success(res.message || "App version updated successfully")
onUpdated({
...res.data,
platform: res.data.platform || version.platform,
version_name: res.data.version_name || version.version_name,
version_code: res.data.version_code || version.version_code,
created_at: res.data.created_at || version.created_at,
})
onOpenChange(false)
} catch {
toast.error("Failed to update app version.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
<form onSubmit={handleSubmit}>
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Edit app version
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Update release rules and messaging for this version.
</DialogDescription>
</DialogHeader>
{draft && version ? (
<div className="space-y-4 px-6 py-5">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Release
</p>
<p className="mt-0.5 text-sm font-semibold text-grayScale-900">
{versionLabel(version)}
</p>
<p className="text-xs text-grayScale-500">
Platform and version identifiers cannot be changed
</p>
</div>
<Badge variant="secondary">{formatAppPlatform(version.platform)}</Badge>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Update type
</label>
<Select
value={draft.update_type}
onChange={(e) =>
setDraft((d) => d && { ...d, update_type: e.target.value as AppUpdateType })
}
className="rounded-[6px]"
>
{APP_UPDATE_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Status
</label>
<Select
value={draft.status}
onChange={(e) =>
setDraft((d) => d && { ...d, status: e.target.value as AppVersionStatus })
}
className="rounded-[6px]"
>
{APP_VERSION_STATUSES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Min supported code <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={0}
step={1}
value={draft.min_supported_version_code}
onChange={(e) =>
setDraft((d) => d && { ...d, min_supported_version_code: e.target.value })
}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Store URL <span className="text-destructive">*</span>
</label>
<Input
type="url"
value={draft.store_url}
onChange={(e) => setDraft((d) => d && { ...d, store_url: e.target.value })}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Release notes <span className="text-destructive">*</span>
</label>
<Textarea
value={draft.release_notes}
onChange={(e) => setDraft((d) => d && { ...d, release_notes: e.target.value })}
className="min-h-[96px] rounded-[6px]"
required
/>
</div>
</div>
) : null}
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
className="rounded-[6px]"
disabled={saving}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || !draft}
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
>
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,316 @@
import { useEffect, useState } from "react"
import { Save } from "lucide-react"
import { toast } from "sonner"
import { updateSubscriptionPlan } from "../../../api/subscription-plans.api"
import { Badge } from "../../../components/ui/badge"
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 { SpinnerIcon } from "../../../components/ui/spinner-icon"
import { Textarea } from "../../../components/ui/textarea"
import { cn } from "../../../lib/utils"
import {
formatPlanCategory,
SUBSCRIPTION_CURRENCIES,
SUBSCRIPTION_DURATION_UNITS,
} from "../../../lib/subscriptionPlans"
import type {
SubscriptionPlan,
SubscriptionPlanDurationUnit,
UpdateSubscriptionPlanPayload,
} from "../../../types/subscription.types"
interface EditDraft {
name: string
description: string
duration_value: string
duration_unit: SubscriptionPlanDurationUnit
price: string
currency: string
is_active: boolean
}
function planToDraft(plan: SubscriptionPlan): EditDraft {
return {
name: plan.name,
description: plan.description,
duration_value: String(plan.duration_value),
duration_unit: plan.duration_unit,
price: String(plan.price),
currency: plan.currency,
is_active: plan.is_active,
}
}
function draftToPayload(draft: EditDraft): UpdateSubscriptionPlanPayload | null {
const name = draft.name.trim()
const description = draft.description.trim()
const duration_value = Number(draft.duration_value)
const price = Number(draft.price)
if (!name) return null
if (!description) return null
if (!Number.isFinite(duration_value) || duration_value < 1) return null
if (!Number.isFinite(price) || price < 0) return null
return {
name,
description,
duration_value,
duration_unit: draft.duration_unit,
price,
currency: draft.currency,
is_active: draft.is_active,
}
}
type EditSubscriptionPlanDialogProps = {
plan: SubscriptionPlan | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: (plan: SubscriptionPlan) => void
}
export function EditSubscriptionPlanDialog({
plan,
open,
onOpenChange,
onUpdated,
}: EditSubscriptionPlanDialogProps) {
const [draft, setDraft] = useState<EditDraft | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open && plan) {
setDraft(planToDraft(plan))
setSaving(false)
}
if (!open) {
setDraft(null)
setSaving(false)
}
}, [open, plan])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!plan || !draft) return
const payload = draftToPayload(draft)
if (!payload) {
toast.error("Please fill in all required fields with valid values.")
return
}
setSaving(true)
try {
const res = await updateSubscriptionPlan(plan.id, payload)
if (!res.data) {
toast.error("Plan was updated but the response could not be read.")
return
}
toast.success(res.message || "Subscription plan updated successfully")
onUpdated({
...res.data,
category: res.data.category || plan.category,
created_at: res.data.created_at || plan.created_at,
})
onOpenChange(false)
} catch {
toast.error("Failed to update subscription plan.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
<form onSubmit={handleSubmit}>
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Edit subscription package
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Update pricing, duration, and visibility for this plan.
</DialogDescription>
</DialogHeader>
{draft && plan ? (
<div className="space-y-4 px-6 py-5">
<div className="flex items-center justify-between gap-3 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Category
</p>
<p className="mt-0.5 text-xs text-grayScale-500">
Category cannot be changed after creation
</p>
</div>
<Badge variant="secondary">{formatPlanCategory(plan.category)}</Badge>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Package name <span className="text-destructive">*</span>
</label>
<Input
value={draft.name}
onChange={(e) => setDraft((d) => d && { ...d, name: e.target.value })}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Description <span className="text-destructive">*</span>
</label>
<Textarea
value={draft.description}
onChange={(e) => setDraft((d) => d && { ...d, description: e.target.value })}
className="min-h-[88px] rounded-[6px]"
required
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Duration <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={1}
step={1}
value={draft.duration_value}
onChange={(e) =>
setDraft((d) => d && { ...d, duration_value: e.target.value })
}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Duration unit
</label>
<Select
value={draft.duration_unit}
onChange={(e) =>
setDraft((d) =>
d
? {
...d,
duration_unit: e.target.value as SubscriptionPlanDurationUnit,
}
: d,
)
}
className="rounded-[6px]"
>
{SUBSCRIPTION_DURATION_UNITS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Price <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={0}
step="0.01"
value={draft.price}
onChange={(e) => setDraft((d) => d && { ...d, price: e.target.value })}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Currency
</label>
<Select
value={draft.currency}
onChange={(e) => setDraft((d) => d && { ...d, currency: e.target.value })}
className="rounded-[6px]"
>
{SUBSCRIPTION_CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
</div>
</div>
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
<div>
<p className="text-sm font-medium text-grayScale-800">Active package</p>
<p className="text-xs text-grayScale-500">
Inactive plans stay in the catalog but are hidden from checkout
</p>
</div>
<button
type="button"
role="switch"
aria-checked={draft.is_active}
onClick={() => setDraft((d) => d && { ...d, is_active: !d.is_active })}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
draft.is_active ? "bg-brand-500" : "bg-grayScale-200",
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
draft.is_active ? "translate-x-5" : "translate-x-0.5",
)}
/>
</button>
</label>
</div>
) : null}
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
className="rounded-[6px]"
disabled={saving}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || !draft}
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
>
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,75 @@
import { cn } from "../../../lib/utils"
import type { ResolvedTheme } from "../../../lib/theme"
type ThemeModePreviewProps = {
variant: "light" | "dark" | "system"
systemResolved?: ResolvedTheme
className?: string
}
/** Mini UI mockup so theme options look distinct before applying. */
export function ThemeModePreview({
variant,
systemResolved = "light",
className,
}: ThemeModePreviewProps) {
if (variant === "system") {
return (
<div
className={cn(
"relative h-14 w-full overflow-hidden rounded-md border border-grayScale-200 shadow-inner",
className,
)}
>
<div className="absolute inset-0 grid grid-cols-2">
<div className="flex flex-col gap-1 bg-[#f5f5f5] p-1.5">
<div className="h-1.5 w-8 rounded-sm bg-white shadow-sm" />
<div className="mt-auto h-4 rounded-sm bg-white shadow-sm" />
</div>
<div className="flex flex-col gap-1 bg-[#12121a] p-1.5">
<div className="h-1.5 w-8 rounded-sm bg-[#2e2e3a]" />
<div className="mt-auto h-4 rounded-sm bg-[#1c1c24]" />
</div>
</div>
<span className="absolute bottom-1 right-1 rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-white">
{systemResolved}
</span>
</div>
)
}
const isDark = variant === "dark"
return (
<div
className={cn(
"flex h-14 w-full flex-col gap-1 rounded-md border p-1.5 shadow-inner",
isDark
? "border-[#2e2e3a] bg-[#12121a]"
: "border-grayScale-200 bg-[#f5f5f5]",
className,
)}
>
<div
className={cn(
"h-1.5 w-10 rounded-sm",
isDark ? "bg-[#2e2e3a]" : "bg-white shadow-sm",
)}
/>
<div className="flex flex-1 gap-1">
<div
className={cn(
"flex-1 rounded-sm",
isDark ? "bg-[#1c1c24]" : "bg-white shadow-sm",
)}
/>
<div
className={cn(
"w-4 rounded-sm",
isDark ? "bg-[#9E2891]" : "bg-[#9E2891]",
)}
/>
</div>
</div>
)
}

View File

@ -20,6 +20,7 @@ import {
} from "../../components/ui/table";
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
import type { TeamMember } from "../../types/team.types";
import { toast } from "sonner";
@ -413,7 +414,7 @@ export function TeamManagementPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -36,9 +36,11 @@ import {
DialogDescription,
} from "../../components/ui/dialog";
import { cn } from "../../lib/utils";
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
import { ActorHoverCard } from "./components/ActorHoverCard";
// ── Action type configuration ──────────────────────────────────────
const ACTION_TYPES = [
@ -425,21 +427,26 @@ export function UserLogPage() {
</p>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
<User className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-600">
ID: {log.actor_id ?? "System"}
</p>
{log.actor_role && (
<p className="text-xs text-grayScale-400">
{formatRoleLabel(log.actor_role)}
<ActorHoverCard
actorId={log.actor_id}
actorRole={log.actor_role}
>
<div className="flex items-center gap-2 rounded-lg px-1 py-0.5 transition-colors hover:bg-grayScale-50">
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
<User className="h-3.5 w-3.5" />
</div>
<div>
<p className="text-sm font-medium text-grayScale-600">
ID: {log.actor_id ?? "System"}
</p>
)}
{log.actor_role && (
<p className="text-xs text-grayScale-400">
{formatRoleLabel(log.actor_role)}
</p>
)}
</div>
</div>
</div>
</ActorHoverCard>
</TableCell>
<TableCell>
<div>
@ -494,7 +501,7 @@ export function UserLogPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[5, 10, 20, 30, 50].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

View File

@ -0,0 +1,251 @@
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"
import { createPortal } from "react-dom"
import { Mail, Shield, User } from "lucide-react"
import { cn } from "../../../lib/utils"
import {
fetchActorProfile,
formatActorDate,
type ActorProfile,
} from "../../../lib/activityLogActor"
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
const HOVER_DELAY_MS = 280
const HIDE_DELAY_MS = 120
type ActorHoverCardProps = {
actorId: number | null
actorRole: string | null
children: ReactNode
}
export function ActorHoverCard({ actorId, actorRole, children }: ActorHoverCardProps) {
const tooltipId = useId()
const triggerRef = useRef<HTMLDivElement>(null)
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const requestRef = useRef(0)
const [open, setOpen] = useState(false)
const [visible, setVisible] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [profile, setProfile] = useState<ActorProfile | null>(null)
const [position, setPosition] = useState({ top: 0, left: 0 })
const updatePosition = useCallback(() => {
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
const cardWidth = 288
const gap = 10
let left = rect.right + gap
if (left + cardWidth > window.innerWidth - 12) {
left = rect.left - cardWidth - gap
}
setPosition({
top: rect.top + rect.height / 2,
left: Math.max(12, left),
})
}, [])
const clearTimers = useCallback(() => {
if (showTimerRef.current) {
clearTimeout(showTimerRef.current)
showTimerRef.current = null
}
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current)
hideTimerRef.current = null
}
}, [])
const close = useCallback(() => {
clearTimers()
setOpen(false)
setVisible(false)
}, [clearTimers])
const loadProfile = useCallback(async () => {
if (actorId == null) return
const requestId = ++requestRef.current
setLoading(true)
setError(null)
try {
const data = await fetchActorProfile(actorId, actorRole)
if (requestId !== requestRef.current) return
setProfile(data)
} catch {
if (requestId !== requestRef.current) return
setProfile(null)
setError("Could not load actor details")
} finally {
if (requestId === requestRef.current) setLoading(false)
}
}, [actorId, actorRole])
const handleEnter = useCallback(() => {
if (actorId == null) return
clearTimers()
hideTimerRef.current = setTimeout(() => {
updatePosition()
setOpen(true)
requestAnimationFrame(() => setVisible(true))
void loadProfile()
}, HOVER_DELAY_MS)
}, [actorId, clearTimers, loadProfile, updatePosition])
const handleLeave = useCallback(() => {
clearTimers()
showTimerRef.current = setTimeout(() => {
setVisible(false)
hideTimerRef.current = setTimeout(() => {
setOpen(false)
setProfile(null)
setError(null)
}, 180)
}, HIDE_DELAY_MS)
}, [clearTimers])
useEffect(() => {
if (!open) return
const onScrollOrResize = () => updatePosition()
window.addEventListener("scroll", onScrollOrResize, true)
window.addEventListener("resize", onScrollOrResize)
return () => {
window.removeEventListener("scroll", onScrollOrResize, true)
window.removeEventListener("resize", onScrollOrResize)
}
}, [open, updatePosition])
useEffect(() => () => clearTimers(), [clearTimers])
if (actorId == null) {
return <>{children}</>
}
return (
<>
<div
ref={triggerRef}
className="inline-flex cursor-default"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onFocus={handleEnter}
onBlur={handleLeave}
aria-describedby={open ? tooltipId : undefined}
>
{children}
</div>
{open &&
createPortal(
<div
id={tooltipId}
role="tooltip"
className={cn(
"fixed z-[100] w-72 -translate-y-1/2 rounded-xl border border-grayScale-100 bg-white p-4 shadow-lg transition-all duration-200 ease-out",
visible ? "translate-x-0 opacity-100" : "translate-x-1 opacity-0",
)}
style={{ top: position.top, left: position.left }}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-grayScale-500">
<SpinnerIcon className="h-5 w-5 text-brand-500" />
Loading
</div>
) : error ? (
<p className="py-4 text-center text-sm text-grayScale-500">{error}</p>
) : profile ? (
<ActorProfileContent profile={profile} />
) : null}
</div>,
document.body,
)}
</>
)
}
function ActorProfileContent({ profile }: { profile: ActorProfile }) {
const isTeam = profile.kind === "team"
return (
<div className="space-y-3">
<div className="flex items-start gap-3">
<div
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-lg",
isTeam ? "bg-brand-50 text-brand-600" : "bg-mint-50 text-mint-700",
)}
>
{isTeam ? <Shield className="h-5 w-5" /> : <User className="h-5 w-5" />}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-grayScale-900">{profile.name}</p>
<p className="text-[11px] font-medium uppercase tracking-wide text-grayScale-400">
{isTeam ? "Team member" : "Learner"} · #{profile.id}
</p>
</div>
</div>
<dl className="space-y-2 text-sm">
<DetailRow icon={<Mail className="h-3.5 w-3.5" />} label="Email" value={profile.email} />
<DetailRow label="Role" value={profile.roleLabel} />
<DetailRow label="Status" value={profile.status} capitalize />
<DetailRow
label="Email verified"
value={profile.emailVerified ? "Yes" : "No"}
/>
{profile.kind === "user" ? (
<>
<DetailRow label="Location" value={`${profile.region}, ${profile.country}`} />
<DetailRow
label="Last login"
value={
profile.lastLogin ? formatActorDate(profile.lastLogin) : "Never"
}
/>
<DetailRow label="Subscription" value={profile.subscriptionStatus} />
</>
) : null}
<DetailRow label="Joined" value={formatActorDate(profile.createdAt)} />
</dl>
</div>
)
}
function DetailRow({
icon,
label,
value,
capitalize,
}: {
icon?: ReactNode
label: string
value: string
capitalize?: boolean
}) {
return (
<div className="flex gap-2">
{icon ? (
<span className="mt-0.5 shrink-0 text-grayScale-400">{icon}</span>
) : (
<span className="w-3.5 shrink-0" />
)}
<div className="min-w-0 flex-1">
<dt className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
{label}
</dt>
<dd
className={cn(
"truncate font-medium text-grayScale-700",
capitalize && "capitalize",
)}
title={value}
>
{value}
</dd>
</div>
</div>
)
}

View File

@ -16,6 +16,7 @@ import {
import { getDeletionRequests } from "../../api/users.api"
import { getRoles } from "../../api/rbac.api"
import { cn } from "../../lib/utils"
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import type {
DeletionRequest,
DeletionState,
@ -580,7 +581,7 @@ export function DeletionRequestsPage() {
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{[10, 20, 50, 100].map((size) => (
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>

Some files were not shown because too many files have changed in this diff Show More