Compare commits
3 Commits
e75420e756
...
1014f4a72f
| Author | SHA1 | Date | |
|---|---|---|---|
| 1014f4a72f | |||
| 92a2fab833 | |||
| 2c3f0da6f7 |
21
index.html
21
index.html
|
|
@ -5,6 +5,27 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>yimaru-admin</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
33
src/App.tsx
33
src/App.tsx
|
|
@ -1,9 +1,29 @@
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { AppRoutes } from './app/AppRoutes'
|
import { AppRoutes } from './app/AppRoutes'
|
||||||
|
import { useTheme } from './contexts/ThemeContext'
|
||||||
|
|
||||||
const SESSION_KEY = 'yimaru_session_active'
|
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() {
|
export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionStorage.getItem(SESSION_KEY)) {
|
if (!sessionStorage.getItem(SESSION_KEY)) {
|
||||||
|
|
@ -18,18 +38,7 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
<Toaster
|
<AppToaster />
|
||||||
position="top-center"
|
|
||||||
toastOptions={{
|
|
||||||
className: 'font-sans',
|
|
||||||
style: {
|
|
||||||
padding: '14px 20px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
richColors
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ function normalizeDashboardUsers(raw: unknown, root?: Record<string, unknown>):
|
||||||
by_knowledge_level: asLabelCounts(
|
by_knowledge_level: asLabelCounts(
|
||||||
pickField(u, "by_knowledge_level", "byKnowledgeLevel", "ByKnowledgeLevel"),
|
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")),
|
by_region: asLabelCounts(pickField(u, "by_region", "byRegion", "ByRegion")),
|
||||||
registrations_last_30_days: asDateCounts(
|
registrations_last_30_days: asDateCounts(
|
||||||
pickField(
|
pickField(
|
||||||
|
|
|
||||||
118
src/api/app-versions.api.ts
Normal file
118
src/api/app-versions.api.ts
Normal 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,
|
||||||
|
}))
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import http from "./http"
|
import http from "./http"
|
||||||
|
|
||||||
export type UploadMediaType = "image" | "audio" | "video"
|
export type UploadMediaType = "image" | "audio" | "video" | "pdf"
|
||||||
export type UploadProvider = "MINIO" | "VIMEO"
|
export type UploadProvider = "MINIO" | "VIMEO"
|
||||||
|
|
||||||
export interface UploadMediaResponse {
|
export interface UploadMediaResponse {
|
||||||
|
|
@ -121,6 +121,8 @@ export const uploadVideoFile = (fileOrUrl: File | string, options?: UploadMediaO
|
||||||
})
|
})
|
||||||
: uploadMediaFile("video", fileOrUrl, options)
|
: uploadMediaFile("video", fileOrUrl, options)
|
||||||
|
|
||||||
|
export const uploadPdfFile = (file: File) => uploadMediaFile("pdf", file)
|
||||||
|
|
||||||
export const resolveFileUrl = (key: string) =>
|
export const resolveFileUrl = (key: string) =>
|
||||||
http.get<ResolveFileUrlResponse>("/files/url", {
|
http.get<ResolveFileUrlResponse>("/files/url", {
|
||||||
params: { key },
|
params: { key },
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,125 @@
|
||||||
import http from "./http";
|
import http from "./http"
|
||||||
import type { GetNotificationsResponse, UnreadCountResponse } from "../types/notification.types";
|
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) =>
|
export const getNotifications = (limit = 10, offset = 0) =>
|
||||||
http.get<GetNotificationsResponse>("/notifications", {
|
http.get<unknown>("/notifications", { params: { limit, offset } }).then((res) => ({
|
||||||
params: { limit, offset },
|
...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 = () =>
|
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) =>
|
export const markAsRead = (id: string) =>
|
||||||
http.patch(`/notifications/${id}/read`);
|
http.patch(`/notifications/${id}/read`)
|
||||||
|
|
||||||
export const markAsUnread = (id: string) =>
|
export const markAsUnread = (id: string) =>
|
||||||
http.patch(`/notifications/${id}/unread`);
|
http.patch(`/notifications/${id}/unread`)
|
||||||
|
|
||||||
export const markAllRead = () =>
|
export const markAllRead = () =>
|
||||||
http.post("/notifications/mark-all-read");
|
http.post("/notifications/mark-all-read")
|
||||||
|
|
||||||
export const markAllUnread = () =>
|
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 }) =>
|
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) =>
|
export const sendBulkEmail = (formData: FormData) =>
|
||||||
http.post("/notifications/bulk-email", formData, {
|
http.post("/notifications/bulk-email", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
})
|
||||||
|
|
||||||
export const sendBulkPush = (formData: FormData) =>
|
export const sendBulkPush = (formData: FormData) =>
|
||||||
http.post("/notifications/bulk-push", formData, {
|
http.post("/notifications/bulk-push", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
})
|
||||||
|
|
|
||||||
98
src/api/payments.api.ts
Normal file
98
src/api/payments.api.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -261,6 +261,40 @@ export function extractDefinitionMutationId(res: { data?: unknown }): number | u
|
||||||
/** @deprecated use extractDefinitionMutationId */
|
/** @deprecated use extractDefinitionMutationId */
|
||||||
export const extractCreatedDefinitionId = 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[] {
|
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
|
||||||
if (!payload) return []
|
if (!payload) return []
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
|
|
@ -270,30 +304,32 @@ export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[]
|
||||||
}
|
}
|
||||||
if (typeof payload === "object" && payload !== null) {
|
if (typeof payload === "object" && payload !== null) {
|
||||||
const o = payload as Record<string, unknown>
|
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 (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
|
const data = o.data ?? o.Data
|
||||||
if (Array.isArray(data)) return parseDefinitionsList(data)
|
if (Array.isArray(data)) return parseDefinitionsList(data)
|
||||||
if (data && typeof data === "object") {
|
if (data && typeof data === "object") return parseDefinitionsList(data)
|
||||||
const single = normalizeTypeDefinitionFromApi(data)
|
|
||||||
return single ? [single] : []
|
|
||||||
}
|
|
||||||
const single = normalizeTypeDefinitionFromApi(payload)
|
const single = normalizeTypeDefinitionFromApi(payload)
|
||||||
return single ? [single] : []
|
return single ? [single] : []
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQuestionTypeDefinitions(params?: {
|
export async function getQuestionTypeDefinitions(
|
||||||
include_system?: boolean
|
params?: QuestionTypeDefinitionsListParams,
|
||||||
status?: string
|
): Promise<QuestionTypeDefinitionsListResult> {
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}) {
|
|
||||||
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
|
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
|
||||||
const raw = unwrapApiPayload(res) ?? res.data
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,84 @@
|
||||||
import http from "./http"
|
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 = () =>
|
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,
|
...res,
|
||||||
data: {
|
data: plan,
|
||||||
...res.data,
|
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
||||||
data: Array.isArray(res.data?.data) ? res.data.data : ([] as SubscriptionPlan[]),
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import type {
|
||||||
VerifyInvitationResponse,
|
VerifyInvitationResponse,
|
||||||
} from "../types/teamInvitation.types"
|
} from "../types/teamInvitation.types"
|
||||||
import type {
|
import type {
|
||||||
|
ChangeTeamMemberPasswordRequest,
|
||||||
|
ChangeTeamMemberPasswordResponse,
|
||||||
GetTeamMembersResponse,
|
GetTeamMembersResponse,
|
||||||
GetTeamMemberResponse,
|
GetTeamMemberResponse,
|
||||||
CreateTeamMemberRequest,
|
CreateTeamMemberRequest,
|
||||||
|
|
@ -33,6 +35,10 @@ export const updateTeamMemberStatus = (id: number, status: string) =>
|
||||||
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
||||||
http.put(`/team/members/${id}`, data)
|
http.put(`/team/members/${id}`, data)
|
||||||
|
|
||||||
|
/** POST /team/members/: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). */
|
/** POST /team/members/invite — send invitation email (permission: team.members.invite). */
|
||||||
export const inviteTeamMember = (data: InviteTeamMemberRequest) =>
|
export const inviteTeamMember = (data: InviteTeamMemberRequest) =>
|
||||||
http.post<InviteTeamMemberResponse>("/team/members/invite", data)
|
http.post<InviteTeamMemberResponse>("/team/members/invite", data)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLan
|
||||||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
||||||
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
||||||
import { IssuesPage } from "../pages/issues/IssuesPage";
|
import { IssuesPage } from "../pages/issues/IssuesPage";
|
||||||
|
import { PaymentsPage } from "../pages/payments/PaymentsPage";
|
||||||
import { ProfilePage } from "../pages/ProfilePage";
|
import { ProfilePage } from "../pages/ProfilePage";
|
||||||
import { SettingsPage } from "../pages/SettingsPage";
|
import { SettingsPage } from "../pages/SettingsPage";
|
||||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
|
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
|
||||||
|
|
@ -255,6 +256,7 @@ export function AppRoutes() {
|
||||||
path="/notifications/create"
|
path="/notifications/create"
|
||||||
element={<CreateNotificationPage />}
|
element={<CreateNotificationPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/payments" element={<PaymentsPage />} />
|
||||||
<Route path="/user-log" element={<UserLogPage />} />
|
<Route path="/user-log" element={<UserLogPage />} />
|
||||||
<Route path="/issues" element={<IssuesPage />} />
|
<Route path="/issues" element={<IssuesPage />} />
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,18 @@ import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
} from "react"
|
} 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 { toast } from "sonner"
|
||||||
import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
|
import { uploadAudioFile, uploadImageFile, uploadPdfFile } from "../../api/files.api"
|
||||||
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { Textarea } from "../ui/textarea"
|
import { Textarea } from "../ui/textarea"
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { ResolvedImage } from "../media/ResolvedImage"
|
import { ResolvedImage } from "../media/ResolvedImage"
|
||||||
|
import { DynamicTableBuilder } from "./DynamicTableBuilder"
|
||||||
|
import { slotLabel } from "../../lib/schemaSlotLabel"
|
||||||
|
|
||||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||||
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
const MAX_AUDIO_BYTES = 50 * 1024 * 1024
|
||||||
|
|
@ -28,13 +31,43 @@ export interface DynamicSchemaSlotRow {
|
||||||
required?: boolean
|
required?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function slotMediaMode(kind: string): "image" | "audio" | "text" {
|
function slotMediaMode(
|
||||||
|
kind: string,
|
||||||
|
): "image" | "audio" | "pdf" | "table" | "seconds" | "text" {
|
||||||
const u = kind.trim().toUpperCase()
|
const u = kind.trim().toUpperCase()
|
||||||
if (u === "IMAGE") return "image"
|
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"
|
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 {
|
function isHttpUrl(s: string): boolean {
|
||||||
return /^https?:\/\//i.test(s.trim())
|
return /^https?:\/\//i.test(s.trim())
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +170,9 @@ function DynamicImageSlot({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
<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>
|
||||||
<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="grid grid-cols-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,36%)] lg:items-start lg:gap-4">
|
||||||
<div
|
<div
|
||||||
|
|
@ -407,7 +442,9 @@ function DynamicAudioSlot({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
<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>
|
||||||
<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="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]">
|
<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
|
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({
|
export function DynamicSchemaSlotField({
|
||||||
row,
|
row,
|
||||||
value,
|
value,
|
||||||
|
|
@ -544,24 +680,52 @@ export function DynamicSchemaSlotField({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DynamicSchemaSlotFieldProps) {
|
}: DynamicSchemaSlotFieldProps) {
|
||||||
const mode = slotMediaMode(row.kind)
|
const mode = slotMediaMode(row.kind)
|
||||||
const baseLabel =
|
const fieldLabel = `${slotLabel(row)}${row.required ? " *" : ""}`
|
||||||
row.label?.trim() ||
|
|
||||||
(mode === "image" ? "Image" : mode === "audio" ? "Audio" : row.kind)
|
if (mode === "table") {
|
||||||
const slotLabel = `${baseLabel}${row.required ? " *" : ""}`
|
return (
|
||||||
const slotMeta = `${row.id} · ${row.kind}`
|
<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") {
|
if (mode === "text") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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">{fieldLabel}</label>
|
||||||
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
|
|
||||||
<span className="text-[11px] font-mono text-grayScale-500">{slotMeta}</span>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={3}
|
rows={3}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.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"
|
className="min-h-[88px] resize-y rounded-lg border-grayScale-200 font-mono text-sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
@ -575,8 +739,20 @@ export function DynamicSchemaSlotField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
slotLabel={slotLabel}
|
slotLabel={fieldLabel}
|
||||||
slotMeta={slotMeta}
|
slotMeta=""
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "pdf") {
|
||||||
|
return (
|
||||||
|
<DynamicPdfSlot
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
slotLabel={fieldLabel}
|
||||||
|
slotMeta=""
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -586,8 +762,8 @@ export function DynamicSchemaSlotField({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
slotLabel={slotLabel}
|
slotLabel={fieldLabel}
|
||||||
slotMeta={slotMeta}
|
slotMeta=""
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
250
src/components/content-management/DynamicTableBuilder.tsx
Normal file
250
src/components/content-management/DynamicTableBuilder.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -636,8 +636,8 @@ export function PracticeQuestionEditorFields({
|
||||||
setDefinitionsLoading(true)
|
setDefinitionsLoading(true)
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
|
||||||
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
|
if (!cancelled) setTypeDefinitions(rows)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setTypeDefinitions([])
|
if (!cancelled) setTypeDefinitions([])
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -778,9 +778,8 @@ export function PracticeQuestionEditorFields({
|
||||||
{value.questionType === "DYNAMIC" && (
|
{value.questionType === "DYNAMIC" && (
|
||||||
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
|
<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">
|
<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
|
Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
|
||||||
(imports via <code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>). Other
|
fields accept text or structured values where noted.
|
||||||
slots: text or JSON.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
|
||||||
|
|
|
||||||
169
src/components/notifications/NotificationDetailDialog.tsx
Normal file
169
src/components/notifications/NotificationDetailDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,11 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
CreditCard,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Shield,
|
|
||||||
UserCircle2,
|
UserCircle2,
|
||||||
Users,
|
Users,
|
||||||
Users2,
|
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -33,32 +32,71 @@ type NavGroupItem = {
|
||||||
kind: "group";
|
kind: "group";
|
||||||
label: string;
|
label: string;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
|
activePaths?: string[];
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
children: { label: string; to: string; end?: boolean }[];
|
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[] = [
|
const navEntries: NavEntry[] = [
|
||||||
|
{ kind: "section", label: "Overview" },
|
||||||
{ kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
{ kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||||
{ kind: "link", label: "User Management", to: "/users", icon: Users },
|
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||||
{ kind: "link", label: "Role Management", to: "/roles", icon: Shield },
|
|
||||||
{ kind: "link", label: "Content Management", to: "/content", icon: BookOpen },
|
{ kind: "section", label: "People" },
|
||||||
{ kind: "link", label: "New Content", to: "/new-content", icon: BookOpen },
|
{
|
||||||
|
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",
|
kind: "group",
|
||||||
label: "Notifications",
|
label: "Notifications",
|
||||||
basePath: "/notifications",
|
basePath: "/notifications",
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
children: [
|
children: [
|
||||||
{ label: "My Notifications", to: "/notifications", end: true },
|
{ label: "Inbox", to: "/notifications", end: true },
|
||||||
{ label: "Email Templates", to: "/notifications/email-templates" },
|
{ 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: "section", label: "Operations" },
|
||||||
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
|
{ kind: "link", label: "Payments", to: "/payments", icon: CreditCard },
|
||||||
{ kind: "link", label: "Team Management", to: "/team", icon: Users2 },
|
{ 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: "Profile", to: "/profile", icon: UserCircle2 },
|
||||||
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
|
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
@ -162,19 +200,50 @@ export function Sidebar({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
<nav className="mt-6 flex-1 space-y-0.5 overflow-y-auto">
|
||||||
{navEntries.map((entry) => {
|
{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") {
|
if (entry.kind === "group") {
|
||||||
|
const isNotifications = entry.basePath === "/notifications";
|
||||||
return (
|
return (
|
||||||
<SidebarNavGroup
|
<SidebarNavGroup
|
||||||
key={entry.basePath}
|
key={entry.basePath}
|
||||||
label={entry.label}
|
label={entry.label}
|
||||||
icon={entry.icon}
|
icon={entry.icon}
|
||||||
basePath={entry.basePath}
|
basePath={entry.basePath}
|
||||||
|
activePaths={entry.activePaths}
|
||||||
children={entry.children}
|
children={entry.children}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onNavigate={onClose}
|
onNavigate={onClose}
|
||||||
trailing={!isCollapsed ? unreadBadge : collapsedUnreadDot}
|
trailing={
|
||||||
|
isNotifications
|
||||||
|
? !isCollapsed
|
||||||
|
? unreadBadge
|
||||||
|
: collapsedUnreadDot
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ type SidebarNavGroupProps = {
|
||||||
label: string;
|
label: string;
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
|
/** When set, any matching prefix marks the group active (e.g. `/content` and `/new-content`). */
|
||||||
|
activePaths?: string[];
|
||||||
children: SidebarNavChild[];
|
children: SidebarNavChild[];
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
|
|
@ -23,6 +25,7 @@ export function SidebarNavGroup({
|
||||||
label,
|
label,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
basePath,
|
basePath,
|
||||||
|
activePaths,
|
||||||
children,
|
children,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
|
@ -30,7 +33,8 @@ export function SidebarNavGroup({
|
||||||
}: SidebarNavGroupProps) {
|
}: SidebarNavGroupProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const panelId = useId();
|
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);
|
const [expanded, setExpanded] = useState(isSectionActive);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,32 @@
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import { Bell, BellOff, CheckCheck, Mail, MailOpen } from "lucide-react"
|
||||||
Bell,
|
import { toast } from "sonner"
|
||||||
BellOff,
|
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Megaphone,
|
|
||||||
UserPlus,
|
|
||||||
CreditCard,
|
|
||||||
BookOpen,
|
|
||||||
Video,
|
|
||||||
ShieldAlert,
|
|
||||||
MailOpen,
|
|
||||||
Mail,
|
|
||||||
CheckCheck,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { SpinnerIcon } from "../ui/spinner-icon"
|
import { SpinnerIcon } from "../ui/spinner-icon"
|
||||||
|
import { getNotificationById } from "../../api/notifications.api"
|
||||||
import { useNotifications } from "../../hooks/useNotifications"
|
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"
|
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({
|
function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
|
onOpen,
|
||||||
onMarkRead,
|
onMarkRead,
|
||||||
onMarkUnread,
|
onMarkUnread,
|
||||||
}: {
|
}: {
|
||||||
notification: Notification
|
notification: Notification
|
||||||
|
onOpen: (notification: Notification) => void
|
||||||
onMarkRead: (id: string) => void
|
onMarkRead: (id: string) => void
|
||||||
onMarkUnread: (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
|
const Icon = cfg.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -77,31 +35,26 @@ function NotificationItem({
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
"group relative flex w-full gap-3 rounded-lg px-3 py-3 text-left transition-colors hover:bg-grayScale-100",
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => onOpen(notification)}
|
||||||
if (!notification.is_read) onMarkRead(notification.id)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Unread dot */}
|
|
||||||
{!notification.is_read && (
|
{!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" />
|
<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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-3 grid h-9 w-9 shrink-0 place-items-center rounded-lg",
|
"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)} />
|
<Icon className={cn("h-4 w-4", cfg.color)} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm leading-snug text-grayScale-900",
|
"text-sm leading-snug text-grayScale-900",
|
||||||
!notification.is_read && "font-semibold"
|
!notification.is_read && "font-semibold",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{getNotificationTitle(notification) || "Notification"}
|
{getNotificationTitle(notification) || "Notification"}
|
||||||
|
|
@ -110,11 +63,10 @@ function NotificationItem({
|
||||||
{getNotificationMessage(notification) || "No preview text available."}
|
{getNotificationMessage(notification) || "No preview text available."}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] text-grayScale-600">
|
<p className="mt-1 text-[11px] text-grayScale-600">
|
||||||
{formatTimestamp(notification.timestamp)}
|
{formatNotificationTimestamp(notification.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Read / Unread toggle */}
|
|
||||||
<button
|
<button
|
||||||
type="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"
|
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() {
|
export function NotificationDropdown() {
|
||||||
const [open, setOpen] = useState(false)
|
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 containerRef = useRef<HTMLDivElement>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const {
|
||||||
|
|
@ -151,7 +108,40 @@ export function NotificationDropdown() {
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
} = useNotifications()
|
} = 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(() => {
|
useEffect(() => {
|
||||||
function handleMouseDown(e: MouseEvent) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
|
@ -165,89 +155,98 @@ export function NotificationDropdown() {
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<>
|
||||||
{/* Bell button */}
|
<div ref={containerRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
aria-label="Notifications"
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
{unreadCount > 0 && (
|
{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">
|
<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}
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown panel */}
|
{open && (
|
||||||
{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="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">
|
||||||
{/* Header */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
<h3 className="text-sm font-semibold text-grayScale-800">Notifications</h3>
|
||||||
<div className="flex items-center gap-2">
|
{unreadCount > 0 && (
|
||||||
<h3 className="text-sm font-semibold text-grayScale-800">
|
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
||||||
Notifications
|
{unreadCount}
|
||||||
</h3>
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Badge variant="default" className="px-1.5 py-0 text-[10px]">
|
<button
|
||||||
{unreadCount}
|
type="button"
|
||||||
</Badge>
|
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>
|
</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
|
<button
|
||||||
type="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"
|
className="w-full rounded-lg py-1.5 text-center text-sm font-medium text-brand-600 transition-colors hover:bg-grayScale-100"
|
||||||
onClick={markAllAsRead}
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
navigate("/notifications")
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CheckCheck className="h-3.5 w-3.5" />
|
View all notifications
|
||||||
Mark all read
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
<NotificationDetailDialog
|
||||||
<div className="max-h-[480px] overflow-y-auto">
|
open={detailOpen}
|
||||||
{loading ? (
|
onOpenChange={setDetailOpen}
|
||||||
<div className="flex items-center justify-center py-12">
|
notification={selectedNotification}
|
||||||
<SpinnerIcon className="h-6 w-6" />
|
loading={detailLoading}
|
||||||
</div>
|
error={detailError}
|
||||||
) : notifications.length === 0 ? (
|
onRetry={
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-grayScale-400">
|
selectedNotificationId
|
||||||
<BellOff className="h-8 w-8" />
|
? () => void loadNotificationDetail(selectedNotificationId, false)
|
||||||
<p className="text-sm">No notifications</p>
|
: undefined
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ const buttonVariants = cva(
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
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",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
outline: "border bg-background hover:bg-grayScale-100",
|
outline: "border bg-background hover:bg-grayScale-100",
|
||||||
ghost: "hover:bg-grayScale-100",
|
ghost: "hover:bg-grayScale-100",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
76
src/contexts/ThemeContext.tsx
Normal file
76
src/contexts/ThemeContext.tsx
Normal 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 }
|
||||||
|
|
@ -3,12 +3,13 @@ import { getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead
|
||||||
import type { Notification } from "../types/notification.types"
|
import type { Notification } from "../types/notification.types"
|
||||||
|
|
||||||
const MAX_DROPDOWN = 5
|
const MAX_DROPDOWN = 5
|
||||||
|
const RECONNECT_MS = 5000
|
||||||
|
|
||||||
function getWsUrl() {
|
function getWsUrl() {
|
||||||
const base = import.meta.env.VITE_API_BASE_URL as string
|
const base = import.meta.env.VITE_API_BASE_URL as string
|
||||||
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
const wsBase = base.replace(/^https/, "wss").replace(/^http/, "ws")
|
||||||
const token = localStorage.getItem("access_token") ?? ""
|
const token = localStorage.getItem("access_token") ?? ""
|
||||||
return `${wsBase}/ws/connect?token=${token}`
|
return `${wsBase}/ws/connect?token=${encodeURIComponent(token)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
|
|
@ -18,6 +19,8 @@ export function useNotifications() {
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const mountedRef = useRef(true)
|
const mountedRef = useRef(true)
|
||||||
|
const intentionalCloseRef = useRef(false)
|
||||||
|
const connectAttemptRef = useRef(0)
|
||||||
|
|
||||||
const dispatchUpdate = () => {
|
const dispatchUpdate = () => {
|
||||||
window.dispatchEvent(new Event("notifications-updated"))
|
window.dispatchEvent(new Event("notifications-updated"))
|
||||||
|
|
@ -40,11 +43,37 @@ export function useNotifications() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const connectWs = useCallback(() => {
|
const clearReconnectTimer = useCallback(() => {
|
||||||
if (wsRef.current) {
|
if (reconnectTimer.current) {
|
||||||
wsRef.current.close()
|
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())
|
const ws = new WebSocket(getWsUrl())
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
|
@ -78,47 +107,45 @@ export function useNotifications() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
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(() => {
|
reconnectTimer.current = setTimeout(() => {
|
||||||
if (mountedRef.current) connectWs()
|
if (mountedRef.current) connectWs()
|
||||||
}, 5000)
|
}, RECONNECT_MS)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [clearReconnectTimer, disconnectWs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true
|
mountedRef.current = true
|
||||||
|
intentionalCloseRef.current = false
|
||||||
fetchData()
|
fetchData()
|
||||||
connectWs()
|
connectWs()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false
|
mountedRef.current = false
|
||||||
wsRef.current?.close()
|
disconnectWs(true)
|
||||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current)
|
|
||||||
}
|
}
|
||||||
}, [fetchData, connectWs])
|
}, [fetchData, connectWs, disconnectWs])
|
||||||
|
|
||||||
const markOneRead = useCallback(async (id: string) => {
|
const markOneRead = useCallback(async (id: string) => {
|
||||||
setNotifications((prev) =>
|
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))
|
setUnreadCount((prev) => Math.max(0, prev - 1))
|
||||||
dispatchUpdate()
|
dispatchUpdate()
|
||||||
try {
|
try {
|
||||||
await markAsRead(id)
|
await markAsRead(id)
|
||||||
} catch {
|
} catch {
|
||||||
// revert on failure
|
|
||||||
await fetchData()
|
await fetchData()
|
||||||
}
|
}
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
const markOneUnread = useCallback(async (id: string) => {
|
const markOneUnread = useCallback(async (id: string) => {
|
||||||
setNotifications((prev) =>
|
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)
|
setUnreadCount((prev) => prev + 1)
|
||||||
dispatchUpdate()
|
dispatchUpdate()
|
||||||
|
|
|
||||||
109
src/index.css
109
src/index.css
|
|
@ -5,7 +5,17 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@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%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
|
@ -38,6 +48,46 @@
|
||||||
--radius: 14px;
|
--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;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +99,61 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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
143
src/lib/activityLogActor.ts
Normal 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
79
src/lib/appVersions.ts
Normal 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
9
src/lib/auth.ts
Normal 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}`
|
||||||
|
}
|
||||||
57
src/lib/dynamicTableValue.ts
Normal file
57
src/lib/dynamicTableValue.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
|
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
|
||||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||||
|
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
|
||||||
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
|
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 {
|
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
|
||||||
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
|
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
|
||||||
}
|
}
|
||||||
|
|
@ -10,11 +19,55 @@ export function emptyDynamicFieldValuesForDefinition(
|
||||||
def: QuestionTypeDefinition,
|
def: QuestionTypeDefinition,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const o: Record<string, string> = {}
|
const o: Record<string, string> = {}
|
||||||
for (const r of def.stimulus_schema) o[`stimulus:${r.id}`] = ""
|
for (const r of def.stimulus_schema) {
|
||||||
for (const r of def.response_schema) o[`response:${r.id}`] = ""
|
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind)
|
||||||
|
}
|
||||||
|
for (const r of def.response_schema) {
|
||||||
|
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind)
|
||||||
|
}
|
||||||
return o
|
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.
|
* System definitions with empty schema map to classic POST /questions types.
|
||||||
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
|
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
|
||||||
|
|
@ -41,6 +94,24 @@ export interface LearnEnglishDefinitionQuestionInput {
|
||||||
sampleAnswerVoiceUrl?: string
|
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(
|
export function buildCreateQuestionFromDefinition(
|
||||||
def: QuestionTypeDefinition,
|
def: QuestionTypeDefinition,
|
||||||
q: LearnEnglishDefinitionQuestionInput,
|
q: LearnEnglishDefinitionQuestionInput,
|
||||||
|
|
@ -51,13 +122,17 @@ export function buildCreateQuestionFromDefinition(
|
||||||
const question_text = q.questionText.trim()
|
const question_text = q.questionText.trim()
|
||||||
|
|
||||||
if (definitionUsesDynamicPayload(def)) {
|
if (definitionUsesDynamicPayload(def)) {
|
||||||
|
const fieldValues = mergePromptIntoDynamicFieldValues(
|
||||||
|
def,
|
||||||
|
q.questionText,
|
||||||
|
q.dynamicFieldValues ?? {},
|
||||||
|
)
|
||||||
const payload = buildDynamicQuestionPayload({
|
const payload = buildDynamicQuestionPayload({
|
||||||
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
|
||||||
responseRows: def.response_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 {
|
return {
|
||||||
question_text,
|
|
||||||
question_type: "DYNAMIC",
|
question_type: "DYNAMIC",
|
||||||
question_type_definition_id: def.id,
|
question_type_definition_id: def.id,
|
||||||
difficulty_level: difficulty,
|
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 {
|
return {
|
||||||
question_text,
|
|
||||||
question_type: "DYNAMIC",
|
question_type: "DYNAMIC",
|
||||||
question_type_definition_id: def.id,
|
question_type_definition_id: def.id,
|
||||||
difficulty_level: difficulty,
|
difficulty_level: difficulty,
|
||||||
|
|
@ -136,24 +209,36 @@ export function validateDefinitionQuestion(
|
||||||
index1Based: number,
|
index1Based: number,
|
||||||
): string | null {
|
): string | null {
|
||||||
const n = index1Based
|
const n = index1Based
|
||||||
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
|
|
||||||
|
|
||||||
if (definitionUsesDynamicPayload(def)) {
|
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) {
|
for (const row of def.stimulus_schema) {
|
||||||
if (!row.required) continue
|
if (!row.required) continue
|
||||||
const v = (q.dynamicFieldValues ?? {})[`stimulus:${row.id}`]?.trim()
|
const v = fieldValues[`stimulus:${row.id}`]?.trim()
|
||||||
if (!v)
|
if (!v)
|
||||||
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
|
||||||
}
|
}
|
||||||
for (const row of def.response_schema) {
|
for (const row of def.response_schema) {
|
||||||
if (!row.required) continue
|
if (!row.required) continue
|
||||||
const v = (q.dynamicFieldValues ?? {})[`response:${row.id}`]?.trim()
|
const v = fieldValues[`response:${row.id}`]?.trim()
|
||||||
if (!v)
|
if (!v)
|
||||||
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
|
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
|
||||||
|
|
||||||
const legacy = legacyQuestionTypeFromDefinition(def)
|
const legacy = legacyQuestionTypeFromDefinition(def)
|
||||||
if (legacy === "MCQ") {
|
if (legacy === "MCQ") {
|
||||||
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
|
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { PracticeParentKind } from "../types/course.types"
|
||||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||||
import {
|
import {
|
||||||
buildCreateQuestionFromDefinition,
|
buildCreateQuestionFromDefinition,
|
||||||
|
questionRowHasContent,
|
||||||
validateDefinitionQuestion,
|
validateDefinitionQuestion,
|
||||||
type LearnEnglishDefinitionQuestionInput,
|
type LearnEnglishDefinitionQuestionInput,
|
||||||
} from "./learnEnglishDefinitionQuestion"
|
} from "./learnEnglishDefinitionQuestion"
|
||||||
|
|
@ -30,9 +31,12 @@ export function validateLearnEnglishQuestionsWithDefinitions(
|
||||||
questions: LearnEnglishDefinitionQuestionInput[],
|
questions: LearnEnglishDefinitionQuestionInput[],
|
||||||
definitions: QuestionTypeDefinition[],
|
definitions: QuestionTypeDefinition[],
|
||||||
): string | null {
|
): 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 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++) {
|
for (let i = 0; i < filled.length; i++) {
|
||||||
const q = filled[i]
|
const q = filled[i]
|
||||||
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
|
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
|
let displayOrder = 0
|
||||||
for (const q of toCreate) {
|
for (const q of toCreate) {
|
||||||
const def = byId.get(q.questionTypeDefinitionId)
|
const def = byId.get(q.questionTypeDefinitionId)
|
||||||
|
|
|
||||||
101
src/lib/notificationDisplay.ts
Normal file
101
src/lib/notificationDisplay.ts
Normal 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
51
src/lib/payments.ts
Normal 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"
|
||||||
|
}
|
||||||
41
src/lib/schemaSlotLabel.ts
Normal file
41
src/lib/schemaSlotLabel.ts
Normal 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)
|
||||||
|
}
|
||||||
61
src/lib/subscriptionPlans.ts
Normal file
61
src/lib/subscriptionPlans.ts
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
6
src/lib/tablePagination.ts
Normal file
6
src/lib/tablePagination.ts
Normal 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
64
src/lib/theme.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<ThemeProvider>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
getVideoLessonsSummary,
|
getVideoLessonsSummary,
|
||||||
} from "../lib/analytics"
|
} from "../lib/analytics"
|
||||||
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
|
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
|
||||||
|
import { formatPlanDuration } from "../lib/subscriptionPlans"
|
||||||
import type { SubscriptionPlan } from "../types/subscription.types"
|
import type { SubscriptionPlan } from "../types/subscription.types"
|
||||||
import type { Rating } from "../types/course.types"
|
import type { Rating } from "../types/course.types"
|
||||||
|
|
||||||
|
|
@ -59,17 +60,6 @@ function formatDate(dateStr: string) {
|
||||||
|
|
||||||
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
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() {
|
export function DashboardPage() {
|
||||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||||
|
|
@ -120,7 +110,7 @@ export function DashboardPage() {
|
||||||
setSubscriptionPlansLoading(true)
|
setSubscriptionPlansLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getSubscriptionPlans()
|
const res = await getSubscriptionPlans()
|
||||||
setSubscriptionPlans(res.data.data)
|
setSubscriptionPlans(res.data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setSubscriptionPlans([])
|
setSubscriptionPlans([])
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Globe,
|
Globe,
|
||||||
KeyRound,
|
|
||||||
Languages,
|
|
||||||
Lock,
|
Lock,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
|
|
@ -14,8 +11,7 @@ import {
|
||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
AlertTriangle,
|
Smartphone,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -26,228 +22,33 @@ import {
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Select } from "../components/ui/select";
|
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 { cn } from "../lib/utils";
|
||||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
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 { getMyProfile, updateProfile } from "../api/users.api";
|
||||||
import type { UserProfileData } from "../types/user.types";
|
import type { UserProfileData } from "../types/user.types";
|
||||||
import { toast } from "sonner";
|
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 =
|
type SettingsTab =
|
||||||
| "subscription"
|
| "subscription"
|
||||||
|
| "app-versions"
|
||||||
| "profile"
|
| "profile"
|
||||||
| "security"
|
| "security"
|
||||||
| "notifications"
|
|
||||||
| "appearance";
|
| "appearance";
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
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: "profile", label: "Profile", icon: User },
|
||||||
{ id: "security", label: "Security", icon: Shield },
|
{ id: "security", label: "Security", icon: Shield },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ 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 }) {
|
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||||
const [firstName, setFirstName] = useState(profile.first_name);
|
const [firstName, setFirstName] = useState(profile.first_name);
|
||||||
const [lastName, setLastName] = useState(profile.last_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 [showCurrent, setShowCurrent] = useState(false);
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await new Promise((r) => setTimeout(r, 600));
|
await changeTeamMemberPassword(memberId, {
|
||||||
toast.success("Password updated successfully");
|
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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -397,7 +227,11 @@ function SecurityTab() {
|
||||||
<Input
|
<Input
|
||||||
type={showCurrent ? "text" : "password"}
|
type={showCurrent ? "text" : "password"}
|
||||||
placeholder="Enter current 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -421,7 +255,11 @@ function SecurityTab() {
|
||||||
<Input
|
<Input
|
||||||
type={showNew ? "text" : "password"}
|
type={showNew ? "text" : "password"}
|
||||||
placeholder="Enter new 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -444,7 +282,11 @@ function SecurityTab() {
|
||||||
<Input
|
<Input
|
||||||
type={showConfirm ? "text" : "password"}
|
type={showConfirm ? "text" : "password"}
|
||||||
placeholder="Confirm new 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -532,52 +374,101 @@ function NotificationsTab() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppearanceTab() {
|
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 (
|
return (
|
||||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
|
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
<div className="h-1 w-full bg-brand-400" />
|
<Card className="overflow-hidden rounded-[6px] border border-grayScale-200">
|
||||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
<div className="h-1 w-full bg-brand-400" />
|
||||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||||
Theme
|
<CardTitle className="text-sm font-bold text-grayScale-600">Theme</CardTitle>
|
||||||
</CardTitle>
|
<p className="text-xs text-grayScale-400">
|
||||||
</CardHeader>
|
Active appearance:{" "}
|
||||||
<CardContent className="pb-6">
|
<span className="font-semibold capitalize text-grayScale-600">{resolvedTheme}</span>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
{theme === "system" ? " (from your device setting)" : null}
|
||||||
{(
|
{theme === "light" ? " (fixed — not tied to device)" : null}
|
||||||
[
|
</p>
|
||||||
{ id: "light", label: "Light", icon: Sun },
|
</CardHeader>
|
||||||
{ id: "dark", label: "Dark", icon: Moon },
|
<CardContent className="pb-6 pt-4">
|
||||||
{ id: "system", label: "System", icon: Globe },
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
] as const
|
{options.map(({ id, label, description, icon: Icon, preview }) => {
|
||||||
).map(({ id, label, icon: Icon }) => (
|
const selected = theme === id;
|
||||||
<button
|
return (
|
||||||
key={id}
|
<button
|
||||||
type="button"
|
key={id}
|
||||||
onClick={() => setTheme(id)}
|
type="button"
|
||||||
className={cn(
|
onClick={() => setTheme(id)}
|
||||||
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
|
className={cn(
|
||||||
theme === id
|
"flex flex-col items-stretch gap-3 rounded-[8px] border-2 p-3 text-left transition-all",
|
||||||
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
|
selected
|
||||||
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
|
? "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",
|
||||||
>
|
)}
|
||||||
<div
|
>
|
||||||
className={cn(
|
<ThemeModePreview
|
||||||
"flex h-10 w-10 items-center justify-center rounded-[6px]",
|
variant={preview}
|
||||||
theme === id
|
systemResolved={systemTheme}
|
||||||
? "bg-brand-500 text-white"
|
/>
|
||||||
: "bg-grayScale-100 text-grayScale-400",
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<div
|
||||||
>
|
className={cn(
|
||||||
<Icon className="h-5 w-5" />
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px]",
|
||||||
</div>
|
selected
|
||||||
<span className="text-sm font-medium">{label}</span>
|
? "bg-brand-500 text-white"
|
||||||
</button>
|
: "bg-grayScale-100 text-grayScale-500",
|
||||||
))}
|
)}
|
||||||
</div>
|
>
|
||||||
</CardContent>
|
<Icon className="h-4 w-4" />
|
||||||
</Card>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex min-w-0 flex-col gap-8 lg:flex-row lg:items-start">
|
||||||
{/* Content Area */}
|
<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">
|
||||||
<main className="min-h-[400px]">
|
{tabs.map((tab) => {
|
||||||
{activeTab === "subscription" && <SubscriptionTab />}
|
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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -665,6 +665,11 @@ export function AnalyticsPage() {
|
||||||
data={users.by_age_group ?? []}
|
data={users.by_age_group ?? []}
|
||||||
total={users.total_users}
|
total={users.total_users}
|
||||||
/>
|
/>
|
||||||
|
<BreakdownList
|
||||||
|
title="Country"
|
||||||
|
data={users.by_country ?? []}
|
||||||
|
total={users.total_users}
|
||||||
|
/>
|
||||||
<BreakdownList
|
<BreakdownList
|
||||||
title="Region"
|
title="Region"
|
||||||
data={users.by_region ?? []}
|
data={users.by_region ?? []}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
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 { Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
import { BrandLogo } from "../../components/brand/BrandLogo";
|
import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||||
|
|
@ -65,9 +65,18 @@ function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
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 [showPassword, setShowPassword] = useState(false);
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
|
||||||
|
|
@ -373,30 +373,34 @@ export function AddNewPracticePage() {
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const qRes = await createQuestion({
|
const qRes = await createQuestion(
|
||||||
question_text: q.questionText,
|
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
|
||||||
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
|
|
||||||
? {
|
? {
|
||||||
|
question_type: "DYNAMIC",
|
||||||
question_type_definition_id: q.questionTypeDefinitionId,
|
question_type_definition_id: q.questionTypeDefinitionId,
|
||||||
dynamic_payload: dynamicPayload,
|
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;
|
const questionId = qRes.data?.data?.id;
|
||||||
if (questionId) {
|
if (questionId) {
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export function AddPracticeFlow() {
|
||||||
setDefinitionsLoading(true);
|
setDefinitionsLoading(true);
|
||||||
setDefinitionsError(null);
|
setDefinitionsError(null);
|
||||||
try {
|
try {
|
||||||
const list = await getQuestionTypeDefinitions({
|
const { definitions: list } = await getQuestionTypeDefinitions({
|
||||||
include_system: true,
|
include_system: true,
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
});
|
});
|
||||||
|
|
@ -216,9 +216,7 @@ export function AddPracticeFlow() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persona = personaFromId(selectedPersona, personas);
|
const persona = personaFromId(selectedPersona, personas);
|
||||||
const mappedQuestions = formData.questions
|
const mappedQuestions = formData.questions.map((q) => ({
|
||||||
.filter((q) => String(q.text ?? "").trim())
|
|
||||||
.map((q) => ({
|
|
||||||
questionText: String(q.text ?? "").trim(),
|
questionText: String(q.text ?? "").trim(),
|
||||||
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||||
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||||
|
|
@ -372,6 +370,7 @@ export function AddPracticeFlow() {
|
||||||
onCancel={() => navigate(backPath)}
|
onCancel={() => navigate(backPath)}
|
||||||
isLessonPractice={isLessonPractice}
|
isLessonPractice={isLessonPractice}
|
||||||
lessonTitle={lessonTitleDisplay}
|
lessonTitle={lessonTitleDisplay}
|
||||||
|
parentSummary={parentSummary}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
|
|
@ -515,9 +514,7 @@ export function AddPracticeFlow() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-grayScale-400 text-base">
|
<p className="text-grayScale-400 text-base">
|
||||||
Create a practice: question types from{" "}
|
Create a practice with story details, a persona, and questions from your question type library.
|
||||||
<code className="text-xs">GET /questions/type-definitions</code>, then
|
|
||||||
question set and POST /practices.
|
|
||||||
</p>
|
</p>
|
||||||
{lessonId ? (
|
{lessonId ? (
|
||||||
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
|
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,8 @@ export function AddQuestionPage() {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
|
||||||
if (!cancelled) setTypeDefinitions(Array.isArray(rows) ? rows : [])
|
if (!cancelled) setTypeDefinitions(rows)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setTypeDefinitions([])
|
if (!cancelled) setTypeDefinitions([])
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +268,7 @@ export function AddQuestionPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -419,7 +419,7 @@ export function AddQuestionPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-600">
|
<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>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={formData.dynamicPayloadJson}
|
value={formData.dynamicPayloadJson}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
|
||||||
|
|
||||||
type CourseWithCategory = Course & { category_name: string }
|
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"
|
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}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
|
||||||
|
|
||||||
export function ContentManagementLayout() {
|
export function ContentManagementLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
<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="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 className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
||||||
|
|
@ -16,8 +15,6 @@ export function ContentManagementLayout() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContentHierarchyList />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<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>
|
<DialogTitle>Edit module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, sort order, and icon (upload or URL). Saved with{" "}
|
Update name, sort order, and icon (upload or URL).
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
|
||||||
PUT /modules/:id
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
} from "../../api/courses.api"
|
} from "../../api/courses.api"
|
||||||
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
|
import type { CategorySubCategoryListItem, CourseCategory } from "../../types/course.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
|
|
||||||
export function CoursesPage() {
|
export function CoursesPage() {
|
||||||
const { categoryId } = useParams<{ categoryId: string }>()
|
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"
|
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}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
} from "../../types/questionTypeDefinition.types"
|
} from "../../types/questionTypeDefinition.types"
|
||||||
import {
|
import {
|
||||||
buildCreatePayload,
|
buildCreatePayload,
|
||||||
|
buildValidateKindsPayload,
|
||||||
validateDefinitionBasic,
|
validateDefinitionBasic,
|
||||||
validateDefinitionKinds,
|
validateDefinitionKinds,
|
||||||
validateDefinitionSchemas,
|
validateDefinitionSchemas,
|
||||||
|
|
@ -29,6 +30,7 @@ import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/Ques
|
||||||
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
|
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
|
||||||
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
|
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
|
||||||
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
|
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
|
||||||
|
import { defaultLabelForKind } from "../../lib/schemaSlotLabel"
|
||||||
|
|
||||||
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
|
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
|
||||||
key: "",
|
key: "",
|
||||||
|
|
@ -45,7 +47,7 @@ function seedSchemaFromKinds(kinds: string[]) {
|
||||||
return kinds.map((k, i) => ({
|
return kinds.map((k, i) => ({
|
||||||
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
|
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
|
||||||
kind: k,
|
kind: k,
|
||||||
label: k.replace(/_/g, " "),
|
label: defaultLabelForKind(k),
|
||||||
required: true as boolean,
|
required: true as boolean,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
@ -58,8 +60,14 @@ function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionC
|
||||||
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
|
||||||
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
|
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
|
||||||
response_component_kinds: [...(def.response_component_kinds ?? [])],
|
response_component_kinds: [...(def.response_component_kinds ?? [])],
|
||||||
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({ ...r })),
|
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({
|
||||||
response_schema: (def.response_schema ?? []).map((r) => ({ ...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 [currentStep, setCurrentStep] = useState(1)
|
||||||
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
|
const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
|
||||||
const [versionName, setVersionName] = useState("Test 1")
|
|
||||||
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
|
const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
|
||||||
const [definitionReady, setDefinitionReady] = useState(!isEdit)
|
const [definitionReady, setDefinitionReady] = useState(!isEdit)
|
||||||
|
const [isSystemDefinition, setIsSystemDefinition] = useState(false)
|
||||||
|
|
||||||
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
|
const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
|
||||||
stimulus_component_kinds: [],
|
stimulus_component_kinds: [],
|
||||||
|
|
@ -134,7 +142,7 @@ export function CreateQuestionTypeFlow() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setDraft(definitionToDraft(def))
|
setDraft(definitionToDraft(def))
|
||||||
setVersionName("Test 1")
|
setIsSystemDefinition(Boolean(def.is_system))
|
||||||
setCurrentStep(1)
|
setCurrentStep(1)
|
||||||
setStepErrors({})
|
setStepErrors({})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -155,7 +163,6 @@ export function CreateQuestionTypeFlow() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
setDraft(initialDraft())
|
setDraft(initialDraft())
|
||||||
setVersionName("Test 1")
|
|
||||||
setCurrentStep(1)
|
setCurrentStep(1)
|
||||||
setStepErrors({})
|
setStepErrors({})
|
||||||
setDefinitionReady(true)
|
setDefinitionReady(true)
|
||||||
|
|
@ -179,15 +186,10 @@ export function CreateQuestionTypeFlow() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNextFromStep2 = () => {
|
const handleNextFromStep2 = () => {
|
||||||
const versionErr: FieldErrorMap = {}
|
|
||||||
if (!versionName.trim()) {
|
|
||||||
versionErr.version_name = "Version name is required."
|
|
||||||
}
|
|
||||||
const eKinds = validateDefinitionKinds(draft, componentCatalog)
|
const eKinds = validateDefinitionKinds(draft, componentCatalog)
|
||||||
const mergedKinds = { ...versionErr, ...eKinds }
|
setStepErrors(eKinds)
|
||||||
setStepErrors(mergedKinds)
|
if (Object.keys(eKinds).length) {
|
||||||
if (Object.keys(mergedKinds).length) {
|
toast.error("Select valid stimulus and response component kinds.")
|
||||||
toast.error("Complete version name and component selections.")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,7 +235,7 @@ export function CreateQuestionTypeFlow() {
|
||||||
navigate(`/new-content/question-types?updated=${id}`)
|
navigate(`/new-content/question-types?updated=${id}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const validation = await validateQuestionTypeDefinition(body)
|
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
toast.error(validation.message || "Invalid question type definition", {
|
toast.error(validation.message || "Invalid question type definition", {
|
||||||
description: validation.error ? String(validation.error) : undefined,
|
description: validation.error ? String(validation.error) : undefined,
|
||||||
|
|
@ -290,20 +292,9 @@ export function CreateQuestionTypeFlow() {
|
||||||
{isEdit ? "Edit question type definition" : "Create question type definition"}
|
{isEdit ? "Edit question type definition" : "Create question type definition"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
|
<p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
|
||||||
{isEdit ? (
|
{isEdit
|
||||||
<>
|
? `Update reusable question type definition #${editDefinitionId}.`
|
||||||
Update definition{" "}
|
: "Build a reusable question type template for dynamic practice and assessment questions."}
|
||||||
<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>.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
|
|
@ -343,8 +334,6 @@ export function CreateQuestionTypeFlow() {
|
||||||
<QuestionTypeConfigStep
|
<QuestionTypeConfigStep
|
||||||
draft={draft}
|
draft={draft}
|
||||||
setDraft={setDraft}
|
setDraft={setDraft}
|
||||||
versionName={versionName}
|
|
||||||
setVersionName={setVersionName}
|
|
||||||
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
|
stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
|
||||||
responseCatalogKinds={componentCatalog.response_component_kinds}
|
responseCatalogKinds={componentCatalog.response_component_kinds}
|
||||||
catalogLoading={catalogLoading}
|
catalogLoading={catalogLoading}
|
||||||
|
|
@ -358,7 +347,12 @@ export function CreateQuestionTypeFlow() {
|
||||||
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
|
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<QuestionTypeReviewPublishStep draft={draft} onBack={handleBack} editDefinitionId={editDefinitionId} />
|
<QuestionTypeReviewPublishStep
|
||||||
|
draft={draft}
|
||||||
|
onBack={handleBack}
|
||||||
|
editDefinitionId={editDefinitionId}
|
||||||
|
isSystem={isSystemDefinition}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -787,7 +787,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -1019,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create module</DialogTitle>
|
<DialogTitle>Create module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add a module to this level. This will call `POST /course-management/modules`.
|
Add a module to this level.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -1140,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update module</DialogTitle>
|
<DialogTitle>Update module</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update this module using `PUT /course-management/modules/:moduleId`.
|
Update this module's name, order, and settings.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1029,7 +1029,7 @@ export function HumanLanguageSubModulePage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Lesson detail</DialogTitle>
|
<DialogTitle>Lesson detail</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Loaded from `GET /course-management/sub-module-lessons/:lessonId`.
|
View and edit lesson details.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -356,15 +356,8 @@ export function LearnEnglishPage() {
|
||||||
Add New Program
|
Add New Program
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
Create a learning program via{" "}
|
Create a new learning program. Add a thumbnail as an image URL or by uploading a
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
file.
|
||||||
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>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{/* Gradient Divider */}
|
{/* Gradient Divider */}
|
||||||
|
|
@ -739,11 +732,7 @@ export function LearnEnglishPage() {
|
||||||
disabled={savingEdit || uploadingEditThumbnail}
|
disabled={savingEdit || uploadingEditThumbnail}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-grayScale-500">
|
<p className="text-xs text-grayScale-500">
|
||||||
Local images are sent to{" "}
|
Uploaded images are stored and used as the program thumbnail.
|
||||||
<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.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -364,12 +364,6 @@ export function LessonPracticesPage() {
|
||||||
total={practices.length}
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -668,15 +668,7 @@ export function ModuleDetailPage() {
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit lesson</DialogTitle>
|
<DialogTitle>Edit lesson</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update details. Video and thumbnail files use{" "}
|
Update lesson details. Uploaded video and thumbnail files are stored automatically.
|
||||||
<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>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4 py-2">
|
||||||
|
|
|
||||||
|
|
@ -1093,9 +1093,6 @@ export function PracticeDetailsPage() {
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-grayScale-500">
|
|
||||||
Uses <span className="font-mono">PUT /practices/{id}</span> with the fields above.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
|
<Button type="button" variant="outline" onClick={() => setEditOpen(false)} disabled={savePracticeLoading}>
|
||||||
|
|
@ -1115,9 +1112,8 @@ export function PracticeDetailsPage() {
|
||||||
<DialogTitle>Delete this practice?</DialogTitle>
|
<DialogTitle>Delete this practice?</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm text-grayScale-600">
|
||||||
This will call <span className="font-mono">DELETE /practices/{id}</span> and remove the practice
|
This permanently removes the practice for this {parentTabCopy[parentTab].label.toLowerCase()}. The linked
|
||||||
for this {parentTabCopy[parentTab].label.toLowerCase()}. The question set is not deleted unless your API
|
question set may remain unless you remove it separately.
|
||||||
cascades.
|
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { Input } from "../../components/ui/input"
|
||||||
import { Select } from "../../components/ui/select"
|
import { Select } from "../../components/ui/select"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
import type { PracticeQuestion, QuestionSetQuestion, QuestionDetail } from "../../types/course.types"
|
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 QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"
|
||||||
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
@ -84,7 +85,7 @@ export function PracticeQuestionsPage() {
|
||||||
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
|
const [loadingDetailIds, setLoadingDetailIds] = useState<Record<number, boolean>>({})
|
||||||
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
|
const [groupBy, setGroupBy] = useState<GroupByOption>("none")
|
||||||
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
|
const [pointsSort, setPointsSort] = useState<PointsSortOption>("desc")
|
||||||
const [pageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
const [totalQuestions, setTotalQuestions] = useState(0)
|
const [totalQuestions, setTotalQuestions] = useState(0)
|
||||||
|
|
||||||
|
|
@ -736,29 +737,56 @@ export function PracticeQuestionsPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{totalQuestions > pageSize && (
|
{totalQuestions > 0 && (
|
||||||
<div className="flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-4 py-3">
|
<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">
|
||||||
<p className="text-sm text-grayScale-500">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))}
|
<span>
|
||||||
</p>
|
Page {currentPage} of {Math.max(1, Math.ceil(totalQuestions / pageSize))} ({totalQuestions}{" "}
|
||||||
<div className="flex items-center gap-2">
|
total)
|
||||||
<Button
|
</span>
|
||||||
variant="outline"
|
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
||||||
size="sm"
|
<span className="flex items-center gap-2">
|
||||||
disabled={currentPage <= 1}
|
Rows per page
|
||||||
onClick={() => void fetchQuestions(currentPage - 1)}
|
<div className="relative">
|
||||||
>
|
<select
|
||||||
Previous
|
value={pageSize}
|
||||||
</Button>
|
onChange={(e) => {
|
||||||
<Button
|
setPageSize(Number(e.target.value))
|
||||||
variant="outline"
|
setCurrentPage(1)
|
||||||
size="sm"
|
void fetchQuestions(1)
|
||||||
disabled={currentPage >= Math.ceil(totalQuestions / pageSize)}
|
}}
|
||||||
onClick={() => void fetchQuestions(currentPage + 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"
|
||||||
>
|
>
|
||||||
Next
|
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
</Button>
|
<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>
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -379,15 +379,8 @@ export function ProgramCoursesPage() {
|
||||||
Add New Course
|
Add New Course
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
Create a course via{" "}
|
Add a new course to this program. Use an image URL or upload a file for the
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
|
thumbnail.
|
||||||
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>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
<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>
|
<DialogTitle>Edit course</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Update name, sort order, and thumbnail. Saved with{" "}
|
Update name, sort order, and thumbnail.
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
|
||||||
PUT /courses/:id
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom"
|
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 { toast } from "sonner"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Card } from "../../components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -15,6 +26,7 @@ import {
|
||||||
} from "../../components/ui/dialog"
|
} from "../../components/ui/dialog"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
import { QuestionTypeCard } from "./components/QuestionTypeCard"
|
import { QuestionTypeCard } from "./components/QuestionTypeCard"
|
||||||
import {
|
import {
|
||||||
deleteQuestionTypeDefinition,
|
deleteQuestionTypeDefinition,
|
||||||
|
|
@ -22,6 +34,23 @@ import {
|
||||||
} from "../../api/questionTypeDefinitions.api"
|
} from "../../api/questionTypeDefinitions.api"
|
||||||
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
|
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() {
|
export function QuestionTypeLibraryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
|
|
@ -30,24 +59,48 @@ export function QuestionTypeLibraryPage() {
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
|
const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
|
||||||
|
const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
|
||||||
const [query, setQuery] = useState("")
|
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 [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
|
||||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
|
const [deleteSubmitting, setDeleteSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const rows = await getQuestionTypeDefinitions({ include_system: true })
|
const isSystemScope = scopeFilter === "system"
|
||||||
setDefinitions(Array.isArray(rows) ? rows : [])
|
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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
toast.error("Failed to load question type definitions")
|
toast.error("Failed to load question type definitions")
|
||||||
setDefinitions([])
|
setDefinitions([])
|
||||||
|
setTotalCount(0)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [offset, pageSize, scopeFilter, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
|
|
@ -67,18 +120,36 @@ export function QuestionTypeLibraryPage() {
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return definitions
|
||||||
return definitions.filter((d) => {
|
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 name = (d.display_name || "").toLowerCase()
|
||||||
const key = (d.key || "").toLowerCase()
|
const key = (d.key || "").toLowerCase()
|
||||||
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
|
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) => {
|
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
|
||||||
if (row.is_system) {
|
if (row.is_system) {
|
||||||
|
|
@ -124,9 +195,7 @@ export function QuestionTypeLibraryPage() {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
|
<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">
|
<p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
|
||||||
Reusable dynamic question type templates from{" "}
|
Reusable templates that define how practice and assessment questions are structured and answered.
|
||||||
<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.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/new-content/question-types/create">
|
<Link to="/new-content/question-types/create">
|
||||||
|
|
@ -138,65 +207,240 @@ export function QuestionTypeLibraryPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6">
|
<Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
<CardHeader className="border-b border-grayScale-100 px-6 py-5">
|
||||||
<div className="relative flex-1">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" />
|
<div className="flex items-center gap-3">
|
||||||
<Input
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||||||
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm"
|
<Layers className="h-5 w-5" aria-hidden />
|
||||||
placeholder="Search by display name, key, or id…"
|
</div>
|
||||||
value={query}
|
<div>
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
<CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
|
||||||
/>
|
<p className="text-xs text-grayScale-500 mt-0.5">
|
||||||
</div>
|
Browse and filter templates from the question type catalog
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
</div>
|
||||||
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">Status</span>
|
<Button
|
||||||
{(["All", "ACTIVE", "INACTIVE"] as const).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab(tab)}
|
variant="outline"
|
||||||
className={cn(
|
size="sm"
|
||||||
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
|
className="rounded-[8px] border-grayScale-200"
|
||||||
activeTab === tab
|
disabled={loading}
|
||||||
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
|
onClick={() => void load()}
|
||||||
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{tab === "All" ? "All" : tab === "ACTIVE" ? "Active" : "Inactive"}
|
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||||
</button>
|
Refresh
|
||||||
))}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</CardHeader>
|
||||||
|
|
||||||
{loading ? (
|
<CardContent className="p-0">
|
||||||
<p className="text-sm text-grayScale-500 px-2">Loading definitions…</p>
|
<div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
|
||||||
) : filtered.length === 0 ? (
|
<div className="relative">
|
||||||
<Card className="p-12 text-center border-dashed border-grayScale-200 rounded-2xl">
|
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||||
<p className="text-grayScale-600 font-medium">No definitions match your filters.</p>
|
<Input
|
||||||
<p className="text-sm text-grayScale-400 mt-2">Create one to get started.</p>
|
className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
|
||||||
</Card>
|
placeholder="Search by display name, key, or id on this page…"
|
||||||
) : (
|
value={query}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
{filtered.map((d) => (
|
/>
|
||||||
<QuestionTypeCard
|
{query ? (
|
||||||
key={d.id}
|
<button
|
||||||
id={d.id}
|
type="button"
|
||||||
definitionKey={d.key}
|
onClick={() => setQuery("")}
|
||||||
display_name={d.display_name}
|
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"
|
||||||
status={d.status}
|
aria-label="Clear search"
|
||||||
is_system={d.is_system}
|
>
|
||||||
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
|
<X className="h-4 w-4" />
|
||||||
responseKindsCount={d.response_component_kinds?.length ?? 0}
|
</button>
|
||||||
deleteDisabled={!!d.is_system}
|
) : null}
|
||||||
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
|
</div>
|
||||||
onDelete={() => openDeleteConfirm(d)}
|
|
||||||
/>
|
<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>
|
<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}>
|
<Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
|
||||||
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
|
<DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
|
||||||
|
|
@ -244,3 +488,32 @@ export function QuestionTypeLibraryPage() {
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { Badge } from "../../components/ui/badge"
|
||||||
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
import { deleteQuestion, getQuestionById, getQuestions, updateQuestion } from "../../api/courses.api"
|
||||||
import type { QuestionDetail } from "../../types/course.types"
|
import type { QuestionDetail } from "../../types/course.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
|
|
||||||
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
type QuestionTypeFilter = "all" | "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO"
|
||||||
type DifficultyFilter = "all" | "EASY" | "MEDIUM" | "HARD"
|
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"
|
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}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
} from "../../components/ui/dropdown-menu"
|
} from "../../components/ui/dropdown-menu"
|
||||||
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
|
import type { Course, CourseCategory, QuestionDetail, QuestionSet, SubCourse } from "../../types/course.types"
|
||||||
import { toast } from "sonner"
|
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 MAX_AUDIO_SIZE_BYTES = 50 * 1024 * 1024
|
||||||
const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "webm", "flac"])
|
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 [audioQuestions, setAudioQuestions] = useState<AudioListQuestion[]>([])
|
||||||
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
const [audioTotalCount, setAudioTotalCount] = useState(0)
|
||||||
const [audioPage, setAudioPage] = useState(1)
|
const [audioPage, setAudioPage] = useState(1)
|
||||||
const [audioPageSize] = useState(12)
|
const [audioPageSize, setAudioPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||||
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
const [practiceOptions, setPracticeOptions] = useState<PracticeFilterOption[]>([])
|
||||||
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
const [selectedPracticeId, setSelectedPracticeId] = useState<string>("")
|
||||||
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
const [practiceFilterOpen, setPracticeFilterOpen] = useState(false)
|
||||||
|
|
@ -1510,31 +1511,58 @@ export function SpeakingPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{audioTotalCount > audioPageSize ? (
|
{audioTotalCount > 0 ? (
|
||||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-grayScale-200 bg-white px-3 py-2">
|
<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">
|
||||||
<p className="text-xs text-grayScale-500 sm:text-sm">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))}
|
<span>
|
||||||
</p>
|
Page {audioPage} of {Math.max(1, Math.ceil(audioTotalCount / audioPageSize))} (
|
||||||
<div className="flex items-center gap-2">
|
{audioTotalCount} total)
|
||||||
<Button
|
</span>
|
||||||
type="button"
|
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
||||||
variant="outline"
|
<span className="flex items-center gap-2">
|
||||||
size="sm"
|
Rows per page
|
||||||
disabled={audioPage <= 1 || loading}
|
<div className="relative">
|
||||||
onClick={() => fetchAudioQuestions(audioPage - 1)}
|
<select
|
||||||
>
|
value={audioPageSize}
|
||||||
Previous
|
disabled={loading}
|
||||||
</Button>
|
onChange={(e) => {
|
||||||
<Button
|
setAudioPageSize(Number(e.target.value))
|
||||||
type="button"
|
void fetchAudioQuestions(1)
|
||||||
variant="outline"
|
}}
|
||||||
size="sm"
|
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||||
disabled={audioPage >= Math.ceil(audioTotalCount / audioPageSize) || loading}
|
>
|
||||||
onClick={() => fetchAudioQuestions(audioPage + 1)}
|
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
>
|
<option key={size} value={size}>
|
||||||
Next
|
{size}
|
||||||
</Button>
|
</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>
|
||||||
|
{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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -111,11 +111,7 @@ export function AddModuleModal({
|
||||||
Add New Module
|
Add New Module
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm text-grayScale-400">
|
<DialogDescription className="text-sm text-grayScale-400">
|
||||||
Create a module with{" "}
|
Add a new module to this course.
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
|
|
||||||
POST /courses/:courseId/modules
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,8 +265,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-grayScale-500">
|
<p className="text-xs text-grayScale-500">
|
||||||
This calls <span className="font-mono">POST /question-sets</span> with{" "}
|
Creates a practice question set for this course, module, or lesson.
|
||||||
<span className="font-mono">set_type: PRACTICE</span>.
|
|
||||||
</p>
|
</p>
|
||||||
<Button type="button" onClick={handleStep1} disabled={saving}>
|
<Button type="button" onClick={handleStep1} disabled={saving}>
|
||||||
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
{saving ? <SpinnerIcon className="h-4 w-4" /> : null}
|
||||||
|
|
@ -278,9 +277,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
{canUseWizard && step === 2 && (
|
{canUseWizard && step === 2 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm text-grayScale-600">
|
||||||
Set id <span className="font-mono font-medium text-grayScale-800">#{questionSetId}</span> — add
|
Add one or more audio questions to question set #{questionSetId}.
|
||||||
one or more <strong>AUDIO</strong> questions. Each is created via{" "}
|
|
||||||
<span className="font-mono">POST /questions</span>.
|
|
||||||
</p>
|
</p>
|
||||||
{questionRows.map((row, idx) => (
|
{questionRows.map((row, idx) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -389,8 +386,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
{canUseWizard && step === 3 && (
|
{canUseWizard && step === 3 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm text-grayScale-600">
|
||||||
Link each question to the set with a display order using{" "}
|
Confirm the order of questions in the set.
|
||||||
<span className="font-mono">POST /question-sets/{id}/questions</span>.
|
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
<ul className="space-y-2 rounded-xl border border-grayScale-200 bg-white p-3">
|
||||||
{createdQuestionIds.map((qid, i) => (
|
{createdQuestionIds.map((qid, i) => (
|
||||||
|
|
@ -422,12 +418,7 @@ export function CreatePracticeWizard({ parent, onCreated }: Props) {
|
||||||
{canUseWizard && step === 4 && parent && (
|
{canUseWizard && step === 4 && parent && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-grayScale-600">
|
<p className="text-sm text-grayScale-600">
|
||||||
Parent:{" "}
|
Linked to {parent.kind.toLowerCase()} #{parent.id} · question set #{questionSetId}
|
||||||
<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>
|
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-grayScale-500">
|
||||||
|
|
|
||||||
|
|
@ -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 { Badge } from "../../../components/ui/badge"
|
||||||
import { Card } from "../../../components/ui/card"
|
import { Card } from "../../../components/ui/card"
|
||||||
import { Button } from "../../../components/ui/button"
|
import { Button } from "../../../components/ui/button"
|
||||||
|
|
@ -42,7 +42,12 @@ export function QuestionTypeCard({
|
||||||
<Shield className="h-3 w-3" />
|
<Shield className="h-3 w-3" />
|
||||||
System
|
System
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>
|
<p className="text-[12px] font-mono text-grayScale-500 break-all">#{id} · {definitionKey}</p>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ interface ContextStepProps {
|
||||||
/** Lesson-linked practice: no title, story description, or story image on step 1. */
|
/** Lesson-linked practice: no title, story description, or story image on step 1. */
|
||||||
isLessonPractice?: boolean;
|
isLessonPractice?: boolean;
|
||||||
lessonTitle?: string | null;
|
lessonTitle?: string | null;
|
||||||
|
parentSummary?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -27,6 +28,7 @@ export function ContextStep({
|
||||||
onCancel,
|
onCancel,
|
||||||
isLessonPractice = false,
|
isLessonPractice = false,
|
||||||
lessonTitle = null,
|
lessonTitle = null,
|
||||||
|
parentSummary = null,
|
||||||
}: ContextStepProps) {
|
}: ContextStepProps) {
|
||||||
const storyFileRef = useRef<HTMLInputElement>(null);
|
const storyFileRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploadingStory, setUploadingStory] = useState(false);
|
const [uploadingStory, setUploadingStory] = useState(false);
|
||||||
|
|
@ -62,16 +64,15 @@ export function ContextStep({
|
||||||
<p className="text-grayScale-600 text-base mt-3">
|
<p className="text-grayScale-600 text-base mt-3">
|
||||||
{isLessonPractice ? (
|
{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">
|
<span className="font-medium text-grayScale-800">
|
||||||
{lessonTitle?.trim() || "the selected lesson"}
|
{lessonTitle?.trim() || "the selected lesson"}
|
||||||
</span>
|
</span>
|
||||||
. Set optional quick tips and question order below.
|
.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Title, story, optional image, shuffle, and quick tips match the create
|
Story fields and question set options used when saving the practice.
|
||||||
practice and question set APIs.
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -90,6 +91,16 @@ export function ContextStep({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8 p-10">
|
<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 ? (
|
{!isLessonPractice ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -132,7 +143,7 @@ export function ContextStep({
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, tips: e.target.value })
|
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"
|
className="min-h-[80px] rounded-xl border-grayScale-200"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ export function PublishStatusField({ value, onChange, disabled, className }: Pro
|
||||||
Publish status <span className="text-red-500">*</span>
|
Publish status <span className="text-red-500">*</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-grayScale-500">
|
<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{" "}
|
Controls whether learners can see this practice after you save.
|
||||||
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">POST /practices</code>.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
|
<div className="flex flex-wrap gap-3" role="radiogroup" aria-label="Publish status">
|
||||||
{(
|
{(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
emptyDynamicFieldValuesForDefinition,
|
emptyDynamicFieldValuesForDefinition,
|
||||||
legacyQuestionTypeFromDefinition,
|
legacyQuestionTypeFromDefinition,
|
||||||
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||||
|
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
function defaultMcqOptions() {
|
function defaultMcqOptions() {
|
||||||
return [
|
return [
|
||||||
|
|
@ -98,9 +100,9 @@ export function QuestionsStep({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-lg border border-violet-200 bg-violet-50/40 p-3">
|
<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">
|
<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 (
|
<span className="font-medium text-grayScale-800">Image, audio, and PDF</span> use upload or URL.{" "}
|
||||||
<code className="rounded bg-white px-0.5 text-[11px]">POST /files/upload</code>
|
<span className="font-medium text-grayScale-800">Table</span> uses the visual table builder. Timer and
|
||||||
). Others: URL, text, or JSON.
|
prep-time slots use seconds. Other slots use text or structured JSON where noted.
|
||||||
</p>
|
</p>
|
||||||
{def.stimulus_schema.length > 0 ? (
|
{def.stimulus_schema.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -307,11 +309,8 @@ export function QuestionsStep({
|
||||||
<div className="space-y-1 px-2">
|
<div className="space-y-1 px-2">
|
||||||
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
|
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
|
||||||
<p className="text-grayScale-400 text-lg">
|
<p className="text-grayScale-400 text-lg">
|
||||||
Question types are loaded from{" "}
|
Choose a question type for each item, then fill in the fields that type requires. Questions are saved
|
||||||
<code className="rounded bg-grayScale-100 px-1 text-sm">
|
when you publish or save the practice.
|
||||||
GET /questions/type-definitions
|
|
||||||
</code>
|
|
||||||
. Pick a type per row, then fill the fields required for that definition.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -403,21 +402,28 @@ export function QuestionsStep({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{def && !definitionUsesDynamicPayload(def) ? (
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
<div className="space-y-3">
|
||||||
Question text
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
</label>
|
Question text
|
||||||
<Input
|
</label>
|
||||||
value={q.text}
|
<Input
|
||||||
onChange={(e) => {
|
value={q.text}
|
||||||
const newQuestions = [...formData.questions];
|
onChange={(e) => {
|
||||||
newQuestions[i].text = e.target.value;
|
const newQuestions = [...formData.questions];
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
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"
|
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>
|
/>
|
||||||
|
</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}
|
{def ? renderTypeSpecificFields(q, i, def) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -451,7 +457,30 @@ export function QuestionsStep({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="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}
|
disabled={definitionsLoading || !!definitionsError || typeDefinitions.length === 0}
|
||||||
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
|
className="h-10 rounded-[6px] bg-brand-500 px-8 font-bold disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -29,10 +29,8 @@ export function QuestionTypeBasicInfoStep({
|
||||||
<div className="p-10 border-b border-grayScale-200">
|
<div className="p-10 border-b border-grayScale-200">
|
||||||
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
|
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 1: Definition basics</h2>
|
||||||
<p className="text-grayScale-500 font-medium mt-1">
|
<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
|
Set the reusable key, display name, and status. On the next step you will choose how questions are
|
||||||
component types from the live catalog (
|
presented and how learners answer.
|
||||||
<code className="text-xs bg-grayScale-100 px-1 rounded">GET /questions/component-catalog</code>
|
|
||||||
).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import {
|
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Hourglass,
|
|
||||||
Plus,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { Button } from "../../../../components/ui/button"
|
import { Button } from "../../../../components/ui/button"
|
||||||
import { Card } from "../../../../components/ui/card"
|
import { Card } from "../../../../components/ui/card"
|
||||||
import { Input } from "../../../../components/ui/input"
|
import { Input } from "../../../../components/ui/input"
|
||||||
|
|
@ -16,14 +9,17 @@ import type {
|
||||||
} from "../../../../types/questionTypeDefinition.types"
|
} from "../../../../types/questionTypeDefinition.types"
|
||||||
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
|
import type { FieldErrorMap } from "../../lib/questionTypeDefinitionValidation"
|
||||||
import { SchemaBuilderSection } from "./SchemaBuilderSection"
|
import { SchemaBuilderSection } from "./SchemaBuilderSection"
|
||||||
|
import { SchemaSlotLabelsPanel } from "./SchemaSlotLabelsPanel"
|
||||||
import { ComponentKindCard } from "./ComponentKindCard"
|
import { ComponentKindCard } from "./ComponentKindCard"
|
||||||
import { getResponseKindPresentation, getStimulusKindPresentation } from "./componentKindUi"
|
import {
|
||||||
|
defaultLabelForKind,
|
||||||
|
getResponseKindPresentation,
|
||||||
|
getStimulusKindPresentation,
|
||||||
|
} from "./componentKindUi"
|
||||||
|
|
||||||
interface QuestionTypeConfigStepProps {
|
interface QuestionTypeConfigStepProps {
|
||||||
draft: QuestionTypeDefinitionCreatePayload
|
draft: QuestionTypeDefinitionCreatePayload
|
||||||
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
|
setDraft: React.Dispatch<React.SetStateAction<QuestionTypeDefinitionCreatePayload>>
|
||||||
versionName: string
|
|
||||||
setVersionName: (v: string) => void
|
|
||||||
stimulusCatalogKinds: string[]
|
stimulusCatalogKinds: string[]
|
||||||
responseCatalogKinds: string[]
|
responseCatalogKinds: string[]
|
||||||
catalogLoading: boolean
|
catalogLoading: boolean
|
||||||
|
|
@ -42,10 +38,6 @@ function slugFragmentFromKind(kind: string): string {
|
||||||
return s.replace(/^_|_$/g, "") || "field"
|
return s.replace(/^_|_$/g, "") || "field"
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultSchemaLabel(kind: string): string {
|
|
||||||
return kind.replace(/_/g, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
|
function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: string): string {
|
||||||
const base = slugFragmentFromKind(kind)
|
const base = slugFragmentFromKind(kind)
|
||||||
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
|
const existing = new Set(rows.map((r) => r.id.trim()).filter(Boolean))
|
||||||
|
|
@ -58,6 +50,22 @@ function nextUniqueSchemaElementId(rows: DynamicElementDefinition[], kind: strin
|
||||||
return id
|
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> {
|
function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Record<number, string> {
|
||||||
const prefix = `${side}_`
|
const prefix = `${side}_`
|
||||||
const out: Record<number, string> = {}
|
const out: Record<number, string> = {}
|
||||||
|
|
@ -73,8 +81,6 @@ function rowErrorMap(side: "stimulus" | "response", errors: FieldErrorMap): Reco
|
||||||
export function QuestionTypeConfigStep({
|
export function QuestionTypeConfigStep({
|
||||||
draft,
|
draft,
|
||||||
setDraft,
|
setDraft,
|
||||||
versionName,
|
|
||||||
setVersionName,
|
|
||||||
stimulusCatalogKinds,
|
stimulusCatalogKinds,
|
||||||
responseCatalogKinds,
|
responseCatalogKinds,
|
||||||
catalogLoading,
|
catalogLoading,
|
||||||
|
|
@ -83,11 +89,8 @@ export function QuestionTypeConfigStep({
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: QuestionTypeConfigStepProps) {
|
}: QuestionTypeConfigStepProps) {
|
||||||
const [panelOpen, setPanelOpen] = useState(true)
|
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
|
||||||
const title = draft.display_name?.trim() || "Untitled definition"
|
|
||||||
|
|
||||||
const handleStimulusKindClick = (kind: string) => {
|
const handleStimulusKindClick = (kind: string) => {
|
||||||
setDraft((d) => {
|
setDraft((d) => {
|
||||||
const wasSelected = d.stimulus_component_kinds.includes(kind)
|
const wasSelected = d.stimulus_component_kinds.includes(kind)
|
||||||
|
|
@ -98,7 +101,7 @@ export function QuestionTypeConfigStep({
|
||||||
stimulus_schema.push({
|
stimulus_schema.push({
|
||||||
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +125,7 @@ export function QuestionTypeConfigStep({
|
||||||
response_schema.push({
|
response_schema.push({
|
||||||
id: nextUniqueSchemaElementId(response_schema, kind),
|
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -143,7 +146,7 @@ export function QuestionTypeConfigStep({
|
||||||
stimulus_schema.push({
|
stimulus_schema.push({
|
||||||
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
id: nextUniqueSchemaElementId(stimulus_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
return { ...d, stimulus_schema }
|
return { ...d, stimulus_schema }
|
||||||
|
|
@ -157,54 +160,63 @@ export function QuestionTypeConfigStep({
|
||||||
response_schema.push({
|
response_schema.push({
|
||||||
id: nextUniqueSchemaElementId(response_schema, kind),
|
id: nextUniqueSchemaElementId(response_schema, kind),
|
||||||
kind,
|
kind,
|
||||||
label: defaultSchemaLabel(kind),
|
label: defaultLabelForKind(kind),
|
||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
return { ...d, response_schema }
|
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 (
|
return (
|
||||||
<div className="space-y-8 pb-32">
|
<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">
|
<Card className="max-w-6xl mx-auto overflow-hidden border border-grayScale-200 shadow-sm rounded-2xl bg-white">
|
||||||
<button
|
<div className="p-10 border-b border-grayScale-200">
|
||||||
type="button"
|
<h2 className="text-[20px] font-medium text-grayScale-900">STEP 2: Input & answer types</h2>
|
||||||
onClick={() => setPanelOpen((o) => !o)}
|
<p className="text-grayScale-500 font-medium mt-1">
|
||||||
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"
|
Choose what learners see in the question and how they respond. Add or remove slots for each type
|
||||||
>
|
as needed.
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
</p>
|
||||||
<div className="h-10 w-10 rounded-xl bg-white/80 flex items-center justify-center text-violet-700 shrink-0 shadow-sm">
|
</div>
|
||||||
<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-6 sm:p-10 space-y-10">
|
||||||
{catalogLoading ? (
|
{catalogLoading ? (
|
||||||
<p className="text-sm text-grayScale-500">Loading component catalog…</p>
|
<p className="text-sm text-grayScale-500">Loading component catalog…</p>
|
||||||
) : catalogError ? (
|
) : catalogError ? (
|
||||||
|
|
@ -217,10 +229,8 @@ export function QuestionTypeConfigStep({
|
||||||
Section A: Question input types
|
Section A: Question input types
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
<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{" "}
|
Choose how the question is presented to the learner. Use Add slot / Remove slot to adjust
|
||||||
<code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_component_kinds</code>{" "}
|
how many fields of each type you need.
|
||||||
while <code className="text-[11px] bg-grayScale-100 px-1 rounded">stimulus_schema</code> can
|
|
||||||
include the same kind multiple times (different ids).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<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">
|
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||||
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
size="sm"
|
||||||
onClick={() => addStimulusSlot(kind)}
|
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
|
||||||
>
|
onClick={() => removeStimulusSlot(kind)}
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
disabled={slotCount === 0}
|
||||||
Add slot
|
aria-label={`Remove ${label} slot`}
|
||||||
</Button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -268,10 +293,8 @@ export function QuestionTypeConfigStep({
|
||||||
Section B: Answer types
|
Section B: Answer types
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
<p className="text-[14px] text-grayScale-500 mt-1 font-medium">
|
||||||
How should the student answer?{" "}
|
How should the student answer? Use Add slot / Remove slot to adjust how many answer fields
|
||||||
<code className="text-[11px] bg-grayScale-100 px-1 rounded">response_component_kinds</code> is
|
each type needs.
|
||||||
deduplicated; use <span className="font-medium text-grayScale-600">Add slot</span> for multiple
|
|
||||||
fields of the same kind.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
<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">
|
<span className="text-[12px] text-grayScale-500 font-medium">
|
||||||
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
{slotCount} slot{slotCount === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-8 shrink-0 text-[12px] font-bold text-[#9E2891] hover:text-[#8A237E] hover:bg-violet-50"
|
size="sm"
|
||||||
onClick={() => addResponseSlot(kind)}
|
className="h-8 shrink-0 text-[12px] font-bold text-grayScale-600 hover:bg-grayScale-100"
|
||||||
>
|
onClick={() => removeResponseSlot(kind)}
|
||||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
disabled={slotCount === 0}
|
||||||
Add slot
|
aria-label={`Remove ${label} slot`}
|
||||||
</Button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -315,6 +353,14 @@ export function QuestionTypeConfigStep({
|
||||||
</div>
|
</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">
|
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50/50 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -336,7 +382,7 @@ export function QuestionTypeConfigStep({
|
||||||
allowedKinds={draft.stimulus_component_kinds}
|
allowedKinds={draft.stimulus_component_kinds}
|
||||||
catalogKinds={stimulusCatalogKinds}
|
catalogKinds={stimulusCatalogKinds}
|
||||||
rows={draft.stimulus_schema}
|
rows={draft.stimulus_schema}
|
||||||
onChange={(rows) => setDraft((d) => ({ ...d, stimulus_schema: rows }))}
|
onChange={setStimulusSchema}
|
||||||
error={errors.stimulus_schema}
|
error={errors.stimulus_schema}
|
||||||
rowErrors={rowErrorMap("stimulus", errors)}
|
rowErrors={rowErrorMap("stimulus", errors)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -346,15 +392,14 @@ export function QuestionTypeConfigStep({
|
||||||
allowedKinds={draft.response_component_kinds}
|
allowedKinds={draft.response_component_kinds}
|
||||||
catalogKinds={responseCatalogKinds}
|
catalogKinds={responseCatalogKinds}
|
||||||
rows={draft.response_schema}
|
rows={draft.response_schema}
|
||||||
onChange={(rows) => setDraft((d) => ({ ...d, response_schema: rows }))}
|
onChange={setResponseSchema}
|
||||||
error={errors.response_schema}
|
error={errors.response_schema}
|
||||||
rowErrors={rowErrorMap("response", errors)}
|
rowErrors={rowErrorMap("response", errors)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
|
<div className="px-4 py-4 border-t border-grayScale-200 flex items-center justify-between bg-[#F8FAFC]">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -11,30 +11,54 @@ import {
|
||||||
validateQuestionTypeDefinition,
|
validateQuestionTypeDefinition,
|
||||||
} from "../../../../api/questionTypeDefinitions.api"
|
} from "../../../../api/questionTypeDefinitions.api"
|
||||||
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
|
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 {
|
interface QuestionTypeReviewPublishStepProps {
|
||||||
draft: QuestionTypeDefinitionCreatePayload
|
draft: QuestionTypeDefinitionCreatePayload
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
/** When set, saves via PUT /questions/type-definitions/:id */
|
/** When set, saves via PUT /questions/type-definitions/:id */
|
||||||
editDefinitionId?: number | null
|
editDefinitionId?: number | null
|
||||||
|
isSystem?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionTypeReviewPublishStep({
|
export function QuestionTypeReviewPublishStep({
|
||||||
draft,
|
draft,
|
||||||
onBack,
|
onBack,
|
||||||
editDefinitionId,
|
editDefinitionId,
|
||||||
|
isSystem,
|
||||||
}: QuestionTypeReviewPublishStepProps) {
|
}: QuestionTypeReviewPublishStepProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const isEdit = editDefinitionId != null && editDefinitionId > 0
|
const isEdit = editDefinitionId != null && editDefinitionId > 0
|
||||||
|
|
||||||
const payload = buildCreatePayload(draft)
|
const payload = buildCreatePayload(draft)
|
||||||
|
const runtime = inferRuntimeQuestionType(payload.key, payload.response_component_kinds)
|
||||||
|
|
||||||
const submit = async (status: "ACTIVE" | "INACTIVE") => {
|
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 }
|
const body = { ...payload, status }
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
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) {
|
if (isEdit) {
|
||||||
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
|
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
|
||||||
const id = extractDefinitionMutationId(res) ?? editDefinitionId
|
const id = extractDefinitionMutationId(res) ?? editDefinitionId
|
||||||
|
|
@ -45,14 +69,6 @@ export function QuestionTypeReviewPublishStep({
|
||||||
return
|
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 res = await createQuestionTypeDefinition(body)
|
||||||
const id = extractDefinitionMutationId(res)
|
const id = extractDefinitionMutationId(res)
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
|
|
@ -81,30 +97,30 @@ export function QuestionTypeReviewPublishStep({
|
||||||
<div className="space-y-8 pb-32">
|
<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">
|
<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">
|
<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 & publish</h2>
|
||||||
<p className="text-grayScale-500 font-medium mt-1">
|
<p className="text-grayScale-500 font-medium mt-1">
|
||||||
{isEdit ? (
|
{isEdit
|
||||||
<>
|
? "Confirm your changes and save. The definition key cannot be changed."
|
||||||
Confirm changes, then update via{" "}
|
: "Confirm your definition, then save it for use when authoring practice questions."}
|
||||||
<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>.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-10 space-y-6">
|
<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">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Key</dt>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Display name</dt>
|
<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>
|
<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>
|
<dd className="font-medium text-grayScale-900 mt-1">{draft.status}</dd>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<dt className="text-grayScale-400 font-semibold uppercase text-[11px] tracking-wide">Stimulus kinds</dt>
|
<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>
|
<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>
|
<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>
|
<dd className="font-medium text-grayScale-900 mt-1">{payload.response_component_kinds.join(", ") || "—"}</dd>
|
||||||
</div>
|
</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>
|
</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">
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-11"
|
className="h-11"
|
||||||
disabled={submitting}
|
disabled={submitting || runtime == null}
|
||||||
onClick={() => void submit("INACTIVE")}
|
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>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
|
className="h-11 bg-[#9E2891] hover:bg-[#8A237E] text-white"
|
||||||
disabled={submitting}
|
disabled={submitting || runtime == null}
|
||||||
onClick={() => void submit("ACTIVE")}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -173,3 +202,35 @@ export function QuestionTypeReviewPublishStep({
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
|
import { ArrowLeft, ArrowRight, CheckCircle2, Loader2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "../../../../components/ui/button"
|
import { Button } from "../../../../components/ui/button"
|
||||||
import { Card } from "../../../../components/ui/card"
|
import { Card } from "../../../../components/ui/card"
|
||||||
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
|
import { validateQuestionTypeDefinition } from "../../../../api/questionTypeDefinitions.api"
|
||||||
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
|
import type { QuestionTypeDefinitionCreatePayload } from "../../../../types/questionTypeDefinition.types"
|
||||||
import { buildCreatePayload } from "../../lib/questionTypeDefinitionValidation"
|
import {
|
||||||
|
buildCreatePayload,
|
||||||
|
buildValidateKindsPayload,
|
||||||
|
} from "../../lib/questionTypeDefinitionValidation"
|
||||||
|
import { DefinitionRuntimeHint } from "./DefinitionRuntimeHint"
|
||||||
|
|
||||||
interface QuestionTypeValidatePreviewStepProps {
|
interface QuestionTypeValidatePreviewStepProps {
|
||||||
draft: QuestionTypeDefinitionCreatePayload
|
draft: QuestionTypeDefinitionCreatePayload
|
||||||
|
|
@ -25,12 +29,12 @@ export function QuestionTypeValidatePreviewStep({
|
||||||
const payload = buildCreatePayload(draft)
|
const payload = buildCreatePayload(draft)
|
||||||
const json = JSON.stringify(payload, null, 2)
|
const json = JSON.stringify(payload, null, 2)
|
||||||
|
|
||||||
const runValidate = async () => {
|
const runValidate = useCallback(async () => {
|
||||||
setValidating(true)
|
setValidating(true)
|
||||||
setServerOk(null)
|
setServerOk(null)
|
||||||
setServerDetail(null)
|
setServerDetail(null)
|
||||||
try {
|
try {
|
||||||
const res = await validateQuestionTypeDefinition(payload)
|
const res = await validateQuestionTypeDefinition(buildValidateKindsPayload(draft))
|
||||||
if (!res.valid) {
|
if (!res.valid) {
|
||||||
setServerOk(false)
|
setServerOk(false)
|
||||||
const detail = res.error || res.message || "Validation failed"
|
const detail = res.error || res.message || "Validation failed"
|
||||||
|
|
@ -39,32 +43,49 @@ export function QuestionTypeValidatePreviewStep({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setServerOk(true)
|
setServerOk(true)
|
||||||
setServerDetail(res.message || "Question type definition is valid.")
|
setServerDetail(res.message || "Component kinds are valid.")
|
||||||
toast.success(res.message || "Question type definition is valid")
|
toast.success(res.message || "Definition kinds validated")
|
||||||
} finally {
|
} finally {
|
||||||
setValidating(false)
|
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 (
|
return (
|
||||||
<div className="space-y-8 pb-32">
|
<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">
|
<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">
|
<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">
|
<p className="text-grayScale-500 font-medium mt-1">
|
||||||
Optional server check via{" "}
|
We check that your stimulus and response selections are valid before you continue. You must pass
|
||||||
<code className="text-xs bg-grayScale-100 px-1 rounded">POST /questions/validate-question-type-definition</code>
|
validation before review.
|
||||||
. 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).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-10 space-y-6">
|
<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">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={runValidate}
|
onClick={() => void runValidate()}
|
||||||
disabled={validating}
|
disabled={validating}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
>
|
>
|
||||||
|
|
@ -74,7 +95,7 @@ export function QuestionTypeValidatePreviewStep({
|
||||||
Validating…
|
Validating…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Validate with server"
|
"Re-validate"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{serverOk === true ? (
|
{serverOk === true ? (
|
||||||
|
|
@ -83,12 +104,49 @@ export function QuestionTypeValidatePreviewStep({
|
||||||
{serverDetail}
|
{serverDetail}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
</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">
|
<div className="space-y-2">
|
||||||
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">Create payload (JSON)</p>
|
<p className="text-[12px] font-bold uppercase tracking-wide text-grayScale-400">
|
||||||
<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">
|
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}
|
{json}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,10 +164,11 @@ export function QuestionTypeValidatePreviewStep({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNext}
|
onClick={handleNext}
|
||||||
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"
|
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" />
|
<ArrowRight className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ import { Input } from "../../../../components/ui/input"
|
||||||
import { Textarea } from "../../../../components/ui/textarea"
|
import { Textarea } from "../../../../components/ui/textarea"
|
||||||
import { Select } from "../../../../components/ui/select"
|
import { Select } from "../../../../components/ui/select"
|
||||||
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
|
import type { DynamicElementDefinition } from "../../../../types/questionTypeDefinition.types"
|
||||||
|
import {
|
||||||
|
defaultLabelForKind,
|
||||||
|
getResponseKindPresentation,
|
||||||
|
getStimulusKindPresentation,
|
||||||
|
} from "./componentKindUi"
|
||||||
|
|
||||||
type Side = "stimulus" | "response"
|
type Side = "stimulus" | "response"
|
||||||
|
|
||||||
|
|
@ -23,7 +28,7 @@ function emptyRow(allowedKinds: string[]): DynamicElementDefinition {
|
||||||
return {
|
return {
|
||||||
id: "",
|
id: "",
|
||||||
kind: first,
|
kind: first,
|
||||||
label: "",
|
label: first ? defaultLabelForKind(first) : "",
|
||||||
required: true,
|
required: true,
|
||||||
config: undefined,
|
config: undefined,
|
||||||
}
|
}
|
||||||
|
|
@ -43,10 +48,24 @@ export function SchemaBuilderSection({
|
||||||
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
|
allowedKinds.length > 0 ? allowedKinds : catalogKinds.length > 0 ? catalogKinds : []
|
||||||
|
|
||||||
const updateRow = (index: number, patch: Partial<DynamicElementDefinition>) => {
|
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)
|
onChange(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kindPresentation = (kind: string) =>
|
||||||
|
side === "stimulus" ? getStimulusKindPresentation(kind) : getResponseKindPresentation(kind)
|
||||||
|
|
||||||
const commitConfigString = (index: number, raw: string) => {
|
const commitConfigString = (index: number, raw: string) => {
|
||||||
const trimmed = raw.trim()
|
const trimmed = raw.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|
@ -73,9 +92,8 @@ export function SchemaBuilderSection({
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
|
<h3 className="text-[16px] font-bold text-grayScale-900">{title}</h3>
|
||||||
<p className="text-[13px] text-grayScale-500 mt-0.5">
|
<p className="text-[13px] text-grayScale-500 mt-0.5">
|
||||||
Each row defines one element in the{" "}
|
Fine-tune slot ids, labels, required flags, and optional config. Labels are the field titles
|
||||||
{side === "stimulus" ? "stimulus" : "response"} schema (id, kind, label, required, optional JSON
|
authors see when creating questions.
|
||||||
config).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -145,7 +163,7 @@ export function SchemaBuilderSection({
|
||||||
>
|
>
|
||||||
{kindOptions.map((k) => (
|
{kindOptions.map((k) => (
|
||||||
<option key={k} value={k}>
|
<option key={k} value={k}>
|
||||||
{k}
|
{kindPresentation(k).label} ({k})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -154,11 +172,13 @@ export function SchemaBuilderSection({
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
value={row.label ?? ""}
|
value={row.label ?? ""}
|
||||||
onChange={(e) => updateRow(index, { label: e.target.value })}
|
onChange={(e) => updateRow(index, { label: e.target.value })}
|
||||||
placeholder="Author-facing label"
|
placeholder={defaultLabelForKind(row.kind)}
|
||||||
className="h-10 bg-white"
|
className="h-10 bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -20,34 +20,35 @@ import {
|
||||||
Volume2,
|
Volume2,
|
||||||
} from "lucide-react"
|
} 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> = {
|
const STIMULUS_LABELS: Record<string, string> = {
|
||||||
QUESTION_TEXT: "Question Text",
|
QUESTION_TEXT: defaultLabelForKind("QUESTION_TEXT"),
|
||||||
PREP_TIME: "Prep Time",
|
PREP_TIME: defaultLabelForKind("PREP_TIME"),
|
||||||
INSTRUCTION: "Instruction",
|
INSTRUCTION: defaultLabelForKind("INSTRUCTION"),
|
||||||
AUDIO_PROMPT: "Audio Prompt",
|
AUDIO_PROMPT: defaultLabelForKind("AUDIO_PROMPT"),
|
||||||
AUDIO_CLIP: "Audio Clip",
|
TEXT_PASSAGE: defaultLabelForKind("TEXT_PASSAGE"),
|
||||||
TEXT_PASSAGE: "Text Passage",
|
IMAGE: defaultLabelForKind("IMAGE"),
|
||||||
IMAGE: "Image",
|
MATCHING_INPUTS: defaultLabelForKind("MATCHING_INPUTS"),
|
||||||
CHART: "Chart",
|
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
|
||||||
MATCHING_INPUTS: "Matching Inputs",
|
TABLE: defaultLabelForKind("TABLE"),
|
||||||
SELECT_MISSING_WORDS: "Select Missing Words",
|
PDF_ATTACHMENT: defaultLabelForKind("PDF_ATTACHMENT"),
|
||||||
TABLE: "Table",
|
|
||||||
FLOW_CHART: "Flow Chart",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESPONSE_LABELS: Record<string, string> = {
|
const RESPONSE_LABELS: Record<string, string> = {
|
||||||
AUDIO_RESPONSE: "Audio Response",
|
AUDIO_RESPONSE: defaultLabelForKind("AUDIO_RESPONSE"),
|
||||||
TEXT_INPUT: "Text Input",
|
TEXT_INPUT: defaultLabelForKind("TEXT_INPUT"),
|
||||||
SHORT_ANSWER: "Short Answer",
|
SHORT_ANSWER: defaultLabelForKind("SHORT_ANSWER"),
|
||||||
MULTIPLE_CHOICE: "Multiple Choice",
|
MULTIPLE_CHOICE: defaultLabelForKind("MULTIPLE_CHOICE"),
|
||||||
OPTION: "Options",
|
OPTION: defaultLabelForKind("OPTION"),
|
||||||
ANSWER_TIMER: "Answer Timer",
|
ANSWER_TIMER: defaultLabelForKind("ANSWER_TIMER"),
|
||||||
SELECT_MISSING_WORDS: "Select Missing Words",
|
SELECT_MISSING_WORDS: defaultLabelForKind("SELECT_MISSING_WORDS"),
|
||||||
PDF_UPLOAD: "PDF Upload",
|
PDF_UPLOAD: defaultLabelForKind("PDF_UPLOAD"),
|
||||||
MATCHING_ANSWER: "Matching Answer",
|
MATCHING_ANSWER: defaultLabelForKind("MATCHING_ANSWER"),
|
||||||
LABEL_SELECTION: "Label Selection",
|
LABEL_SELECTION: defaultLabelForKind("LABEL_SELECTION"),
|
||||||
SEQUENCE_ORDER: "Sequence Order",
|
SEQUENCE_ORDER: defaultLabelForKind("SEQUENCE_ORDER"),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy screenshot labels → map to closest API kind for display only (same code path). */
|
/** 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,
|
AUDIO_CLIP: Volume2,
|
||||||
TEXT_PASSAGE: FileText,
|
TEXT_PASSAGE: FileText,
|
||||||
IMAGE: ImageIcon,
|
IMAGE: ImageIcon,
|
||||||
CHART: BarChart3,
|
|
||||||
MATCHING_INPUTS: Link2,
|
MATCHING_INPUTS: Link2,
|
||||||
SELECT_MISSING_WORDS: ListTodo,
|
SELECT_MISSING_WORDS: ListTodo,
|
||||||
TABLE: TableIcon,
|
TABLE: TableIcon,
|
||||||
FLOW_CHART: GitBranch,
|
PDF_ATTACHMENT: FileUp,
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESPONSE_ICONS: Record<string, LucideIcon> = {
|
const RESPONSE_ICONS: Record<string, LucideIcon> = {
|
||||||
|
|
@ -82,13 +82,6 @@ const RESPONSE_ICONS: Record<string, LucideIcon> = {
|
||||||
|
|
||||||
const DEFAULT_ICON = FileText
|
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 } {
|
export function getStimulusKindPresentation(kind: string): { label: string; Icon: LucideIcon } {
|
||||||
return {
|
return {
|
||||||
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),
|
label: STIMULUS_LABELS[kind] ?? humanizeKind(kind),
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,8 @@ export function VideoDetailStep({
|
||||||
Video
|
Video
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-grayScale-500 ml-1 max-w-2xl">
|
<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
|
Upload a file or paste a link (Vimeo, hosted file, etc.). Uploaded files are stored
|
||||||
sent to your storage via{" "}
|
automatically.
|
||||||
<code className="rounded bg-grayScale-100 px-1 text-[11px]">
|
|
||||||
POST /files/upload
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
<LessonMediaUploadField
|
<LessonMediaUploadField
|
||||||
kind="video"
|
kind="video"
|
||||||
|
|
@ -260,12 +256,8 @@ export function VideoDetailStep({
|
||||||
Pro tip
|
Pro tip
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
<p className="text-[12px] text-grayScale-700 font-medium leading-relaxed">
|
||||||
Use clear titles and a thumbnail that matches the lesson. The
|
Use clear titles and a thumbnail that matches the lesson. The lesson is created when
|
||||||
lesson is created with{" "}
|
you publish.
|
||||||
<code className="rounded bg-white/80 px-1 text-[10px]">
|
|
||||||
POST /modules/:moduleId/lessons
|
|
||||||
</code>{" "}
|
|
||||||
when you publish.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
QuestionComponentCatalog,
|
QuestionComponentCatalog,
|
||||||
QuestionTypeDefinitionCreatePayload,
|
QuestionTypeDefinitionCreatePayload,
|
||||||
} from "../../../types/questionTypeDefinition.types"
|
} from "../../../types/questionTypeDefinition.types"
|
||||||
|
import { defaultLabelForKind } from "../../../lib/schemaSlotLabel"
|
||||||
|
|
||||||
export type FieldErrorMap = Record<string, string>
|
export type FieldErrorMap = Record<string, string>
|
||||||
|
|
||||||
|
|
@ -41,6 +42,18 @@ export function validateDefinitionKinds(
|
||||||
errors.response_kinds = "ANSWER_TIMER cannot be the only response kind."
|
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) {
|
if (catalog) {
|
||||||
const sCat = new Set(catalog.stimulus_component_kinds)
|
const sCat = new Set(catalog.stimulus_component_kinds)
|
||||||
const rCat = new Set(catalog.response_component_kinds)
|
const rCat = new Set(catalog.response_component_kinds)
|
||||||
|
|
@ -87,13 +100,18 @@ export function validateDefinitionSchemas(
|
||||||
const allowedSet = new Set(allowed)
|
const allowedSet = new Set(allowed)
|
||||||
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
|
const catalogSet = side === "stimulus" ? catalog.stimulus : catalog.response
|
||||||
rows.forEach((row, i) => {
|
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)) {
|
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)) {
|
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))
|
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(
|
export function buildCreatePayload(
|
||||||
draft: QuestionTypeDefinitionCreatePayload,
|
draft: QuestionTypeDefinitionCreatePayload,
|
||||||
): QuestionTypeDefinitionCreatePayload {
|
): QuestionTypeDefinitionCreatePayload {
|
||||||
|
|
@ -133,14 +187,14 @@ export function buildCreatePayload(
|
||||||
...r,
|
...r,
|
||||||
id: r.id.trim(),
|
id: r.id.trim(),
|
||||||
kind: r.kind.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,
|
config: r.config && Object.keys(r.config).length ? r.config : undefined,
|
||||||
}))
|
}))
|
||||||
const response_schema = draft.response_schema.map((r) => ({
|
const response_schema = draft.response_schema.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
id: r.id.trim(),
|
id: r.id.trim(),
|
||||||
kind: r.kind.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,
|
config: r.config && Object.keys(r.config).length ? r.config : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||||
import {
|
import {
|
||||||
getIssues,
|
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"
|
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}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -119,11 +119,7 @@ export function CreateEmailTemplatePage() {
|
||||||
New custom template
|
New custom template
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-2xl text-sm text-grayScale-500">
|
<p className="max-w-2xl text-sm text-grayScale-500">
|
||||||
Create a custom template via{" "}
|
Create a custom email template. System templates are managed separately.
|
||||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
|
||||||
POST /admin/email-templates
|
|
||||||
</code>
|
|
||||||
. System templates are managed separately.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,8 @@ export function EmailTemplatesPage() {
|
||||||
Email Templates
|
Email Templates
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||||
Templates from{" "}
|
View and edit email templates used for learner and team notifications. Open a template to
|
||||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
preview and edit its content.
|
||||||
GET /admin/email-templates
|
|
||||||
</code>
|
|
||||||
. Open a template for full preview via slug API.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,9 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
UserPlus,
|
|
||||||
CreditCard,
|
|
||||||
BookOpen,
|
|
||||||
Video,
|
|
||||||
ShieldAlert,
|
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Mail,
|
Mail,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
|
@ -53,6 +45,7 @@ import { cn } from "../../lib/utils"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
|
getNotificationById,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
getUnreadCount,
|
getUnreadCount,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
|
|
@ -63,6 +56,14 @@ import {
|
||||||
sendBulkEmail,
|
sendBulkEmail,
|
||||||
sendBulkPush,
|
sendBulkPush,
|
||||||
} from "../../api/notifications.api"
|
} 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 { getRoles } from "../../api/rbac.api"
|
||||||
import { getTeamMembers } from "../../api/team.api"
|
import { getTeamMembers } from "../../api/team.api"
|
||||||
import { getUsers } from "../../api/users.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 { TeamMember } from "../../types/team.types"
|
||||||
import type { UserApiDTO } from "../../types/user.types"
|
import type { UserApiDTO } from "../../types/user.types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
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(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function digitsOnly(value: string, maxLength: number) {
|
function digitsOnly(value: string, maxLength: number) {
|
||||||
return value.replace(/\D/g, "").slice(0, maxLength)
|
return value.replace(/\D/g, "").slice(0, maxLength)
|
||||||
|
|
@ -149,7 +87,7 @@ function NotificationItem({
|
||||||
onToggleRead: (id: string, currentlyRead: boolean) => void
|
onToggleRead: (id: string, currentlyRead: boolean) => void
|
||||||
toggling: boolean
|
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
|
const Icon = config.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -190,7 +128,7 @@ function NotificationItem({
|
||||||
>
|
>
|
||||||
{getNotificationTitle(notification)}
|
{getNotificationTitle(notification)}
|
||||||
</span>
|
</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}
|
{notification.level}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -206,7 +144,7 @@ function NotificationItem({
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<span className="text-xs text-grayScale-400">
|
<span className="text-xs text-grayScale-400">
|
||||||
{formatTimestamp(notification.timestamp)}
|
{formatNotificationTimestamp(notification.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -236,7 +174,7 @@ function NotificationItem({
|
||||||
{/* Meta row */}
|
{/* Meta row */}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||||
{formatTypeLabel(notification.type)}
|
{formatNotificationTypeLabel(notification.type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
<Badge variant="secondary" className="text-[10px] px-2 py-0">
|
||||||
{notification.delivery_channel}
|
{notification.delivery_channel}
|
||||||
|
|
@ -265,12 +203,16 @@ export function NotificationsPage() {
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
const [globalUnread, setGlobalUnread] = useState(0)
|
const [globalUnread, setGlobalUnread] = useState(0)
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set())
|
||||||
const [bulkLoading, setBulkLoading] = useState(false)
|
const [bulkLoading, setBulkLoading] = useState(false)
|
||||||
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
|
||||||
|
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(null)
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [detailError, setDetailError] = useState(false)
|
||||||
|
|
||||||
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
|
const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all")
|
||||||
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
|
const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all")
|
||||||
|
|
@ -429,7 +371,7 @@ export function NotificationsPage() {
|
||||||
setError(false)
|
setError(false)
|
||||||
try {
|
try {
|
||||||
const [notifRes, unreadRes] = await Promise.all([
|
const [notifRes, unreadRes] = await Promise.all([
|
||||||
getNotifications(PAGE_SIZE, currentOffset),
|
getNotifications(pageSize, currentOffset),
|
||||||
getUnreadCount(),
|
getUnreadCount(),
|
||||||
])
|
])
|
||||||
setNotifications(notifRes.data.notifications ?? [])
|
setNotifications(notifRes.data.notifications ?? [])
|
||||||
|
|
@ -440,11 +382,11 @@ export function NotificationsPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [pageSize])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(offset)
|
fetchData(offset)
|
||||||
}, [offset, fetchData])
|
}, [offset, pageSize, fetchData])
|
||||||
|
|
||||||
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
|
const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => {
|
||||||
setTogglingIds((prev) => new Set(prev).add(id))
|
setTogglingIds((prev) => new Set(prev).add(id))
|
||||||
|
|
@ -495,10 +437,10 @@ export function NotificationsPage() {
|
||||||
}
|
}
|
||||||
}, [totalCount])
|
}, [totalCount])
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))
|
||||||
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
const currentPage = Math.floor(offset / pageSize) + 1
|
||||||
const startEntry = totalCount === 0 ? 0 : offset + 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 getPageNumbers = () => {
|
||||||
const pages: (number | string)[] = []
|
const pages: (number | string)[] = []
|
||||||
|
|
@ -525,7 +467,7 @@ export function NotificationsPage() {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
getNotificationTitle(n),
|
getNotificationTitle(n),
|
||||||
getNotificationMessage(n),
|
getNotificationMessage(n),
|
||||||
formatTypeLabel(n.type),
|
formatNotificationTypeLabel(n.type),
|
||||||
n.delivery_channel,
|
n.delivery_channel,
|
||||||
n.level,
|
n.level,
|
||||||
]
|
]
|
||||||
|
|
@ -537,9 +479,42 @@ export function NotificationsPage() {
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleOpenDetail = (notification: Notification) => {
|
const loadNotificationDetail = useCallback(async (id: string) => {
|
||||||
setSelectedNotification(notification)
|
setDetailLoading(true)
|
||||||
|
setDetailError(false)
|
||||||
|
setSelectedNotification(null)
|
||||||
|
setSelectedNotificationId(id)
|
||||||
setDetailOpen(true)
|
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 (
|
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"
|
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">
|
<span className="truncate">
|
||||||
{typeFilter === "all" ? "All types" : formatTypeLabel(typeFilter)}
|
{typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -766,7 +741,7 @@ export function NotificationsPage() {
|
||||||
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
|
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
|
||||||
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
|
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
|
||||||
<DropdownMenuRadioItem key={t} value={t}>
|
<DropdownMenuRadioItem key={t} value={t}>
|
||||||
{formatTypeLabel(t)}
|
{formatNotificationTypeLabel(t)}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
|
|
@ -830,7 +805,7 @@ export function NotificationsPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredNotifications.map((n) => {
|
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 Icon = config.icon
|
||||||
const isToggling = togglingIds.has(n.id)
|
const isToggling = togglingIds.has(n.id)
|
||||||
return (
|
return (
|
||||||
|
|
@ -854,7 +829,7 @@ export function NotificationsPage() {
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-grayScale-600">
|
<span className="text-xs font-medium text-grayScale-600">
|
||||||
{formatTypeLabel(n.type)}
|
{formatNotificationTypeLabel(n.type)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -880,7 +855,7 @@ export function NotificationsPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={getLevelBadge(n.level)}
|
variant={getNotificationLevelBadge(n.level)}
|
||||||
className="text-[10px] uppercase tracking-wide"
|
className="text-[10px] uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
{n.is_read ? "Read" : "Unread"}
|
{n.is_read ? "Read" : "Unread"}
|
||||||
|
|
@ -888,7 +863,7 @@ export function NotificationsPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell">
|
<TableCell className="hidden sm:table-cell">
|
||||||
<span className="text-xs text-grayScale-400">
|
<span className="text-xs text-grayScale-400">
|
||||||
{formatTimestamp(n.timestamp)}
|
{formatNotificationTimestamp(n.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|
@ -941,18 +916,25 @@ export function NotificationsPage() {
|
||||||
<span className="border-l pl-4">Rows per page</span>
|
<span className="border-l pl-4">Rows per page</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={PAGE_SIZE}
|
value={pageSize}
|
||||||
disabled
|
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"
|
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>
|
</select>
|
||||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - PAGE_SIZE))}
|
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - pageSize))}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
|
"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
|
<button
|
||||||
key={n}
|
key={n}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOffset((n - 1) * PAGE_SIZE)}
|
onClick={() => setOffset((n - 1) * pageSize)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-md border text-sm font-medium",
|
"h-8 w-8 rounded-md border text-sm font-medium",
|
||||||
n === currentPage
|
n === currentPage
|
||||||
|
|
@ -983,7 +965,7 @@ export function NotificationsPage() {
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => currentPage < totalPages && setOffset(offset + PAGE_SIZE)}
|
onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
|
||||||
disabled={currentPage >= totalPages}
|
disabled={currentPage >= totalPages}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
|
"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 */}
|
<NotificationDetailDialog
|
||||||
{selectedNotification && (
|
open={detailOpen}
|
||||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
onOpenChange={setDetailOpen}
|
||||||
<DialogContent>
|
notification={selectedNotification}
|
||||||
<DialogHeader>
|
loading={detailLoading}
|
||||||
<DialogTitle className="flex items-center gap-2">
|
error={detailError}
|
||||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
|
onRetry={
|
||||||
{(() => {
|
selectedNotificationId
|
||||||
const Icon =
|
? () => void loadNotificationDetail(selectedNotificationId)
|
||||||
(TYPE_CONFIG[selectedNotification.type] ?? DEFAULT_TYPE_CONFIG).icon
|
: undefined
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bulk send dialog */}
|
{/* Bulk send dialog */}
|
||||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||||
|
|
|
||||||
|
|
@ -185,10 +185,7 @@ export function EmailTemplateCreateForm({
|
||||||
<Button variant="outline" disabled={saving} onClick={onReset}>
|
<Button variant="outline" disabled={saving} onClick={onReset}>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-grayScale-400">
|
<p className="text-xs text-grayScale-400">Your changes are saved when you submit the form.</p>
|
||||||
Saved with{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1">POST /admin/email-templates</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -102,11 +102,7 @@ export function EmailTemplateEditForm({
|
||||||
<p className="mt-2 text-xs text-grayScale-400">
|
<p className="mt-2 text-xs text-grayScale-400">
|
||||||
Use Go template syntax, e.g.{" "}
|
Use Go template syntax, e.g.{" "}
|
||||||
<code className="rounded bg-grayScale-100 px-1">{`{{if .FirstName}}`}</code>.
|
<code className="rounded bg-grayScale-100 px-1">{`{{if .FirstName}}`}</code>.
|
||||||
Saved with{" "}
|
Your changes are saved when you submit the form.
|
||||||
<code className="rounded bg-grayScale-100 px-1">
|
|
||||||
PUT /admin/email-templates/{template.id}
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
588
src/pages/payments/PaymentsPage.tsx
Normal file
588
src/pages/payments/PaymentsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Search,
|
Search,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -37,6 +38,7 @@ import {
|
||||||
} from "../../api/rbac.api"
|
} from "../../api/rbac.api"
|
||||||
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
import type { Role, RoleDetail, RolePermission } from "../../types/rbac.types"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { teamRoleFromRbacRole } from "../../lib/teamRoles"
|
import { teamRoleFromRbacRole } from "../../lib/teamRoles"
|
||||||
|
|
@ -49,7 +51,7 @@ export function RolesListPage() {
|
||||||
const [roles, setRoles] = useState<Role[]>([])
|
const [roles, setRoles] = useState<Role[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -543,34 +545,59 @@ export function RolesListPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{total > 0 && (
|
||||||
<div className="flex items-center justify-between border-t border-grayScale-100 pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4 text-sm text-grayScale-500">
|
||||||
<p className="text-xs text-grayScale-400">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} roles
|
<span>
|
||||||
</p>
|
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} roles
|
||||||
<div className="flex items-center gap-1">
|
</span>
|
||||||
<Button
|
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
||||||
variant="outline"
|
<span className="flex items-center gap-2">
|
||||||
size="icon"
|
Rows per page
|
||||||
className="h-8 w-8"
|
<div className="relative">
|
||||||
disabled={page <= 1}
|
<select
|
||||||
onClick={() => setPage((p) => p - 1)}
|
value={pageSize}
|
||||||
>
|
onChange={(e) => {
|
||||||
<ChevronLeft className="h-4 w-4" />
|
setPageSize(Number(e.target.value))
|
||||||
</Button>
|
setPage(1)
|
||||||
<span className="px-3 text-xs font-medium text-grayScale-600">
|
}}
|
||||||
{page} / {totalPages}
|
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>
|
</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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -153,12 +153,8 @@ export function InviteTeamMemberDialog({
|
||||||
Invite team members
|
Invite team members
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-left text-grayScale-600">
|
<DialogDescription className="text-left text-grayScale-600">
|
||||||
Sends one{" "}
|
Send one invitation per email address. Invitees complete account setup using the link in
|
||||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
their email
|
||||||
POST /team/members/invite
|
|
||||||
</code>{" "}
|
|
||||||
request per email. Invitees complete setup at{" "}
|
|
||||||
<code className="rounded bg-grayScale-100 px-1 text-xs">/accept-invite</code>
|
|
||||||
{roleLocked ? (
|
{roleLocked ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|
|
||||||
521
src/pages/settings/AppVersionsTab.tsx
Normal file
521
src/pages/settings/AppVersionsTab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
342
src/pages/settings/SubscriptionPlansTab.tsx
Normal file
342
src/pages/settings/SubscriptionPlansTab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
320
src/pages/settings/components/CreateAppVersionDialog.tsx
Normal file
320
src/pages/settings/components/CreateAppVersionDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
307
src/pages/settings/components/CreateSubscriptionPlanDialog.tsx
Normal file
307
src/pages/settings/components/CreateSubscriptionPlanDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
src/pages/settings/components/DeleteAppVersionDialog.tsx
Normal file
98
src/pages/settings/components/DeleteAppVersionDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
274
src/pages/settings/components/EditAppVersionDialog.tsx
Normal file
274
src/pages/settings/components/EditAppVersionDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
316
src/pages/settings/components/EditSubscriptionPlanDialog.tsx
Normal file
316
src/pages/settings/components/EditSubscriptionPlanDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/pages/settings/components/ThemeModePreview.tsx
Normal file
75
src/pages/settings/components/ThemeModePreview.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from "../../components/ui/table";
|
} from "../../components/ui/table";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
|
||||||
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
|
import { getTeamMembers, updateTeamMemberStatus } from "../../api/team.api";
|
||||||
import type { TeamMember } from "../../types/team.types";
|
import type { TeamMember } from "../../types/team.types";
|
||||||
import { toast } from "sonner";
|
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"
|
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}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,11 @@ import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination";
|
||||||
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
|
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
|
||||||
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
|
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
import { SpinnerIcon } from "../../components/ui/spinner-icon";
|
||||||
|
import { ActorHoverCard } from "./components/ActorHoverCard";
|
||||||
|
|
||||||
// ── Action type configuration ──────────────────────────────────────
|
// ── Action type configuration ──────────────────────────────────────
|
||||||
const ACTION_TYPES = [
|
const ACTION_TYPES = [
|
||||||
|
|
@ -425,21 +427,26 @@ export function UserLogPage() {
|
||||||
</p>
|
</p>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<ActorHoverCard
|
||||||
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
|
actorId={log.actor_id}
|
||||||
<User className="h-3.5 w-3.5" />
|
actorRole={log.actor_role}
|
||||||
</div>
|
>
|
||||||
<div>
|
<div className="flex items-center gap-2 rounded-lg px-1 py-0.5 transition-colors hover:bg-grayScale-50">
|
||||||
<p className="text-sm font-medium text-grayScale-600">
|
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
|
||||||
ID: {log.actor_id ?? "System"}
|
<User className="h-3.5 w-3.5" />
|
||||||
</p>
|
</div>
|
||||||
{log.actor_role && (
|
<div>
|
||||||
<p className="text-xs text-grayScale-400">
|
<p className="text-sm font-medium text-grayScale-600">
|
||||||
{formatRoleLabel(log.actor_role)}
|
ID: {log.actor_id ?? "System"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
{log.actor_role && (
|
||||||
|
<p className="text-xs text-grayScale-400">
|
||||||
|
{formatRoleLabel(log.actor_role)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ActorHoverCard>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<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"
|
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}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
251
src/pages/user-log/components/ActorHoverCard.tsx
Normal file
251
src/pages/user-log/components/ActorHoverCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import { getDeletionRequests } from "../../api/users.api"
|
import { getDeletionRequests } from "../../api/users.api"
|
||||||
import { getRoles } from "../../api/rbac.api"
|
import { getRoles } from "../../api/rbac.api"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
|
import { TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
||||||
import type {
|
import type {
|
||||||
DeletionRequest,
|
DeletionRequest,
|
||||||
DeletionState,
|
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"
|
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}>
|
<option key={size} value={size}>
|
||||||
{size}
|
{size}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user