feat(admin): practice edit flow, bulk notifications, and composer UX

Add full practice edit via GET/PUT .../full endpoints with question reorder and collapsible cards. Integrate bulk and scheduled SMS, email, push, and in-app notifications with a scheduled jobs page and improved recipient picker search.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-12 05:26:35 -07:00
parent babbed323c
commit 035d73889e
24 changed files with 3492 additions and 989 deletions

View File

@ -108,6 +108,9 @@ import type {
CreateParentLinkedPracticeRequest,
CreateParentLinkedPracticeResponse,
UpdateParentLinkedPracticeRequest,
GetPracticeFullResponse,
UpdatePracticeFullRequest,
UpdatePracticeFullResponse,
UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest,
PublishStatusOnlyRequest,
@ -846,6 +849,31 @@ export const updateParentLinkedPractice = (
data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** GET /practices/:id/full — Learn English practice with question set and questions. */
export const getLearnEnglishPracticeFull = (practiceId: number) =>
http.get<GetPracticeFullResponse>(`/practices/${practiceId}/full`)
/** PUT /practices/:id/full — atomic update of practice, question set, and questions. */
export const updateLearnEnglishPracticeFull = (
practiceId: number,
data: UpdatePracticeFullRequest,
) =>
http.put<UpdatePracticeFullResponse>(`/practices/${practiceId}/full`, data)
/** GET /exam-prep/practices/:id/full */
export const getExamPrepPracticeFull = (practiceId: number) =>
http.get<GetPracticeFullResponse>(`/exam-prep/practices/${practiceId}/full`)
/** PUT /exam-prep/practices/:id/full */
export const updateExamPrepPracticeFull = (
practiceId: number,
data: UpdatePracticeFullRequest,
) =>
http.put<UpdatePracticeFullResponse>(
`/exam-prep/practices/${practiceId}/full`,
data,
)
/** PUT /practices/:id — set publish_status only (Learn English practice). */
export const setLearnEnglishPracticePublishStatus = (
practiceId: number,

View File

@ -1,9 +1,16 @@
import http from "./http"
import type {
BulkInAppRequest,
BulkSendResult,
BulkSmsRequest,
GetNotificationsResponse,
GetScheduledNotificationsParams,
ListScheduledNotificationsResponse,
Notification,
ScheduledNotification,
UnreadCountResponse,
} from "../types/notification.types"
import { isScheduledNotification, parseBulkResponseData } from "../lib/notificationBulk"
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
@ -111,15 +118,129 @@ export const markAllRead = () =>
export const markAllUnread = () =>
http.post("/notifications/mark-all-unread")
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
http.post("/notifications/bulk-sms", data)
export type BulkSendApiResult =
| { kind: "immediate"; data: BulkSendResult; message: string }
| { kind: "scheduled"; data: ScheduledNotification; message: string }
export const sendBulkEmail = (formData: FormData) =>
http.post("/notifications/bulk-email", formData, {
function parseBulkSendApiResponse(body: unknown, status: number): BulkSendApiResult {
const envelope = isRecord(body) ? body : {}
const message = String(envelope.message ?? "")
const inner = unwrapEnvelopeData(body)
const parsed = parseBulkResponseData(inner)
if (isScheduledNotification(parsed) || status === 201) {
return {
kind: "scheduled",
data: parsed as ScheduledNotification,
message: message || "Notification scheduled",
}
}
return {
kind: "immediate",
data: parsed as BulkSendResult,
message: message || "Notification sent",
}
}
export const sendBulkSms = async (data: BulkSmsRequest): Promise<BulkSendApiResult> => {
const res = await http.post("/notifications/bulk-sms", data)
return parseBulkSendApiResponse(res.data, res.status)
}
export const sendBulkEmail = async (formData: FormData): Promise<BulkSendApiResult> => {
const res = await http.post("/notifications/bulk-email", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
return parseBulkSendApiResponse(res.data, res.status)
}
export const sendBulkPush = (formData: FormData) =>
http.post("/notifications/bulk-push", formData, {
export const sendBulkPush = async (formData: FormData): Promise<BulkSendApiResult> => {
const res = await http.post("/notifications/bulk-push", formData, {
headers: { "Content-Type": "multipart/form-data" },
})
return parseBulkSendApiResponse(res.data, res.status)
}
export const sendBulkInApp = async (data: BulkInAppRequest): Promise<BulkSendApiResult> => {
const res = await http.post("/notifications/bulk-in-app", data)
return parseBulkSendApiResponse(res.data, res.status)
}
function normalizeScheduledNotification(raw: unknown): ScheduledNotification | null {
if (!isRecord(raw)) return null
const id = Number(raw.id)
if (!id) return null
return {
id,
channel: String(raw.channel ?? "") as ScheduledNotification["channel"],
title: raw.title != null ? String(raw.title) : undefined,
message: String(raw.message ?? ""),
html: raw.html != null ? String(raw.html) : undefined,
scheduled_at: String(raw.scheduled_at ?? ""),
status: String(raw.status ?? "pending") as ScheduledNotification["status"],
target_user_ids: Array.isArray(raw.target_user_ids)
? raw.target_user_ids.map((v) => Number(v))
: undefined,
target_role: raw.target_role != null ? String(raw.target_role) : undefined,
target_raw: isRecord(raw.target_raw)
? {
phones: Array.isArray(raw.target_raw.phones)
? raw.target_raw.phones.map(String)
: undefined,
emails: Array.isArray(raw.target_raw.emails)
? raw.target_raw.emails.map(String)
: undefined,
type: raw.target_raw.type != null ? String(raw.target_raw.type) : undefined,
level: raw.target_raw.level != null ? String(raw.target_raw.level) : undefined,
}
: undefined,
attempt_count: raw.attempt_count != null ? Number(raw.attempt_count) : undefined,
last_error: raw.last_error != null ? String(raw.last_error) : undefined,
processing_started_at:
raw.processing_started_at != null ? String(raw.processing_started_at) : null,
sent_at: raw.sent_at != null ? String(raw.sent_at) : null,
cancelled_at: raw.cancelled_at != null ? String(raw.cancelled_at) : null,
created_by: raw.created_by != null ? Number(raw.created_by) : undefined,
created_at: String(raw.created_at ?? ""),
updated_at: String(raw.updated_at ?? ""),
}
}
function parseScheduledList(body: unknown): ListScheduledNotificationsResponse {
const inner = unwrapEnvelopeData(body)
if (!isRecord(inner)) {
return { scheduled_notifications: [], total_count: 0, limit: 20, page: 1 }
}
const rows = Array.isArray(inner.scheduled_notifications)
? inner.scheduled_notifications
: []
const scheduled_notifications = rows
.map(normalizeScheduledNotification)
.filter((row): row is ScheduledNotification => row !== null)
return {
scheduled_notifications,
total_count: Number(inner.total_count ?? scheduled_notifications.length),
limit: Number(inner.limit ?? 20),
page: Number(inner.page ?? 1),
}
}
export const getScheduledNotifications = (params: GetScheduledNotificationsParams = {}) =>
http
.get<unknown>("/notifications/scheduled", { params })
.then((res) => ({ ...res, data: parseScheduledList(res.data) }))
export const getScheduledNotificationById = (id: number) =>
http.get<unknown>(`/notifications/scheduled/${id}`).then((res) => ({
...res,
data: normalizeScheduledNotification(unwrapEnvelopeData(res.data)),
}))
export const cancelScheduledNotification = (id: number) =>
http.post<unknown>(`/notifications/scheduled/${id}/cancel`).then((res) => ({
...res,
data: normalizeScheduledNotification(unwrapEnvelopeData(res.data)),
}))

View File

@ -23,6 +23,7 @@ import { LessonPracticesPage } from "../pages/content-management/LessonPractices
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
import { EditPracticeFlow } from "../pages/content-management/EditPracticeFlow";
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
@ -33,6 +34,7 @@ import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuesti
import { NotFoundPage } from "../pages/NotFoundPage";
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
import { ScheduledNotificationsPage } from "../pages/notifications/ScheduledNotificationsPage";
import { EmailTemplatesPage } from "../pages/notifications/EmailTemplatesPage";
import { EmailTemplateDetailPage } from "../pages/notifications/EmailTemplateDetailPage";
import { CreateEmailTemplatePage } from "../pages/notifications/CreateEmailTemplatePage";
@ -216,6 +218,10 @@ export function AppRoutes() {
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/edit-practice/:practiceId"
element={<EditPracticeFlow />}
/>
<Route
path="/new-content/learn-english"
element={<LearnEnglishPage />}
@ -240,6 +246,18 @@ export function AppRoutes() {
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route
path="/new-content/learn-english/:level/courses/:courseId/edit-practice/:practiceId"
element={<EditPracticeFlow />}
/>
<Route
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/edit-practice/:practiceId"
element={<EditPracticeFlow />}
/>
<Route
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/edit-practice/:practiceId"
element={<EditPracticeFlow />}
/>
<Route
path="/new-content/learn-english/:level/courses/add-practice"
element={<AddPracticeFlow />}
@ -262,6 +280,10 @@ export function AppRoutes() {
path="/notifications/create"
element={<CreateNotificationPage />}
/>
<Route
path="/notifications/scheduled"
element={<ScheduledNotificationsPage />}
/>
<Route path="/payments" element={<PaymentsPage />} />
<Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} />

View File

@ -0,0 +1,173 @@
import { useMemo, useState } from "react"
import { Calendar, Clock3 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "../ui/dropdown-menu"
import { cn } from "../../lib/utils"
import { formatScheduledAtLabel, toRfc3339Utc } from "../../lib/notificationBulk"
function digitsOnly(value: string, maxLength: number) {
return value.replace(/\D/g, "").slice(0, maxLength)
}
type NotificationSchedulePickerProps = {
value: string
onChange: (value: string) => void
disabled?: boolean
className?: string
}
export function NotificationSchedulePicker({
value,
onChange,
disabled,
className,
}: NotificationSchedulePickerProps) {
const [open, setOpen] = useState(false)
const [year, setYear] = useState("")
const [month, setMonth] = useState("")
const [day, setDay] = useState("")
const [hour, setHour] = useState("")
const [minute, setMinute] = useState("")
const label = useMemo(() => formatScheduledAtLabel(value), [value])
const clearFields = () => {
setYear("")
setMonth("")
setDay("")
setHour("")
setMinute("")
onChange("")
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-grayScale-50/70 px-3 text-sm text-grayScale-700 shadow-sm transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100",
disabled && "cursor-not-allowed opacity-50",
className,
)}
>
<span className="truncate text-left">{label}</span>
<span className="ml-2 inline-flex items-center gap-1 rounded-md border border-grayScale-200 bg-white px-2 py-1 text-[11px] text-grayScale-500">
<Calendar className="h-3.5 w-3.5" />
<Clock3 className="h-3.5 w-3.5" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px] p-3">
<p className="mb-2 text-xs font-semibold text-grayScale-500">Schedule notification</p>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Date</label>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="YYYY"
value={year}
onChange={(e) => setYear(digitsOnly(e.target.value, 4))}
inputMode="numeric"
maxLength={4}
className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">-</span>
<Input
type="text"
placeholder="MM"
value={month}
onChange={(e) => setMonth(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">-</span>
<Input
type="text"
placeholder="DD"
value={day}
onChange={(e) => setDay(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
</div>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Time (UTC)</label>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="HH"
value={hour}
onChange={(e) => setHour(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">:</span>
<Input
type="text"
placeholder="MM"
value={minute}
onChange={(e) => setMinute(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => {
const now = new Date()
setYear(String(now.getUTCFullYear()))
setMonth(String(now.getUTCMonth() + 1).padStart(2, "0"))
setDay(String(now.getUTCDate()).padStart(2, "0"))
}}
>
Today
</Button>
<Button type="button" variant="outline" size="sm" className="h-8" onClick={clearFields}>
Clear
</Button>
</div>
<Button
type="button"
size="sm"
className="h-8"
onClick={() => {
const rfc3339 = toRfc3339Utc(year, month, day, hour, minute)
if (!rfc3339) {
toast.error("Invalid schedule time", {
description: "Use YYYY-MM-DD and HH:MM (24h UTC). Time must be in the future.",
})
return
}
onChange(rfc3339)
setOpen(false)
}}
>
Apply
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -88,6 +88,7 @@ const navEntries: NavEntry[] = [
{ label: "Inbox", to: "/notifications", end: true },
{ label: "Email templates", to: "/notifications/email-templates" },
{ label: "Send notification", to: "/notifications/create" },
{ label: "Scheduled", to: "/notifications/scheduled" },
],
},

View File

@ -60,7 +60,7 @@ export function AppLayout() {
}
return (
<div className="flex min-h-screen bg-grayScale-100">
<div className="flex h-dvh min-h-screen overflow-hidden bg-grayScale-100">
<Sidebar
isOpen={sidebarOpen}
isCollapsed={sidebarCollapsed}
@ -68,15 +68,18 @@ export function AppLayout() {
onClose={handleSidebarClose}
/>
<div
className={`flex min-w-0 flex-1 flex-col transition-[margin] duration-300 ${
className={`flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-[margin] duration-300 ${
sidebarCollapsed ? "lg:ml-[88px]" : "lg:ml-[264px]"
}`}
>
<Topbar onSidebarToggle={handleSidebarToggle} />
<main ref={mainRef} className="min-w-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-8 pt-4 sm:px-4 lg:px-6">
<main
ref={mainRef}
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 pb-8 pt-4 sm:px-4 lg:px-6"
>
<Outlet />
</main>
<footer className="border-t bg-grayScale-50 px-4 py-3 lg:px-6">
<footer className="shrink-0 border-t bg-grayScale-50 px-4 py-3 lg:px-6">
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
<span>Powered by</span>
<a

View File

@ -168,6 +168,7 @@ export interface LearnEnglishDefinitionQuestionInput {
dynamicFieldValues: Record<string, string>
difficultyLevel?: QuestionDifficultyLevel
points?: number
displayOrder?: number
mcqOptions?: { option_text: string; is_correct: boolean }[]
trueFalseAnswerIsTrue?: boolean
shortAnswers?: string[]

173
src/lib/notificationBulk.ts Normal file
View File

@ -0,0 +1,173 @@
import type { UserApiDTO } from "../types/user.types"
import type {
BulkSendResult,
PlatformRole,
ScheduledNotification,
} from "../types/notification.types"
import { getUsers } from "../api/users.api"
export const PLATFORM_ROLES: { value: PlatformRole; label: string }[] = [
{ value: "STUDENT", label: "Students" },
{ value: "OPEN_LEARNER", label: "Open learners" },
{ value: "INSTRUCTOR", label: "Instructors" },
{ value: "ADMIN", label: "Admins" },
{ value: "SUPER_ADMIN", label: "Super admins" },
{ value: "SUPPORT", label: "Support" },
]
export const IN_APP_TYPES = [
{ value: "system_alert", label: "System alert" },
{ value: "subscription_expiring", label: "Subscription expiring" },
{ value: "course_completed", label: "Course completed" },
{ value: "payment_verified", label: "Payment verified" },
] as const
export const IN_APP_LEVELS = [
{ value: "info", label: "Info" },
{ value: "warning", label: "Warning" },
{ value: "error", label: "Error" },
{ value: "success", label: "Success" },
] as const
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
export function isScheduledNotification(value: unknown): value is ScheduledNotification {
if (!isRecord(value)) return false
return typeof value.id === "number" && typeof value.scheduled_at === "string"
}
export function parseBulkResponseData(data: unknown): BulkSendResult | ScheduledNotification {
if (isScheduledNotification(data)) return data
if (!isRecord(data)) {
return { sent: 0, failed: 0 }
}
return {
total_recipients:
data.total_recipients != null ? Number(data.total_recipients) : undefined,
sent: Number(data.sent ?? 0),
failed: Number(data.failed ?? 0),
target_users: data.target_users != null ? Number(data.target_users) : undefined,
image: data.image != null ? String(data.image) : undefined,
}
}
export function toRfc3339Utc(
year: string,
month: string,
day: string,
hour: string,
minute: string,
): string | null {
const y = Number(year)
const m = Number(month)
const d = Number(day)
const h = Number(hour)
const min = Number(minute)
const formatOk =
year.length === 4 &&
month.length === 2 &&
day.length === 2 &&
hour.length === 2 &&
minute.length === 2
const dateValue = new Date(y, m - 1, d)
const dateOk =
formatOk &&
m >= 1 &&
m <= 12 &&
d >= 1 &&
d <= 31 &&
dateValue.getFullYear() === y &&
dateValue.getMonth() === m - 1 &&
dateValue.getDate() === d
const timeOk = h >= 0 && h <= 23 && min >= 0 && min <= 59
if (!dateOk || !timeOk) return null
const utc = new Date(Date.UTC(y, m - 1, d, h, min, 0, 0))
if (utc.getTime() <= Date.now()) return null
return utc.toISOString()
}
export function parseDirectRecipients(raw: string): string[] {
return raw
.split(/[\n,;]+/)
.map((s) => s.trim())
.filter(Boolean)
}
export function formatScheduledAtLabel(value: string): string {
if (!value) return "Set date & time"
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
export function scheduledStatusBadgeVariant(
status: string,
): "default" | "secondary" | "destructive" | "outline" {
switch (status) {
case "pending":
return "secondary"
case "processing":
return "default"
case "sent":
return "default"
case "failed":
return "destructive"
case "cancelled":
return "outline"
default:
return "outline"
}
}
export function channelLabel(channel: string): string {
switch (channel) {
case "sms":
return "SMS"
case "email":
return "Email"
case "push":
return "Push"
case "in_app":
return "In-app"
default:
return channel
}
}
export async function fetchAllPlatformUsers(): Promise<UserApiDTO[]> {
const pageSize = 50
const firstRes = await getUsers({ page: 1, page_size: pageSize })
const firstBatch = firstRes.data?.data?.users ?? []
const total = firstRes.data?.data?.total ?? firstBatch.length
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (totalPages <= 1) return firstBatch
const remaining = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) =>
getUsers({ page: i + 2, page_size: pageSize }),
),
)
const rest = remaining.flatMap((r) => r.data?.data?.users ?? [])
return [...firstBatch, ...rest]
}
export function extractApiErrorMessage(err: unknown, fallback: string): string {
if (isRecord(err) && isRecord(err.response) && isRecord(err.response.data)) {
const data = err.response.data
if (typeof data.message === "string" && data.message) return data.message
if (typeof data.error === "string" && data.error) return data.error
}
return fallback
}

View File

@ -104,14 +104,23 @@ export async function executePracticeCreation(
})
const setId = extractCreatedResourceId(setRes, "Could not create question set")
const toCreate = opts.questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
const toCreate = opts.questions
.map((q, index) => ({
q,
sortOrder:
Number.isFinite(q.displayOrder) && (q.displayOrder ?? 0) > 0
? Number(q.displayOrder)
: index + 1,
}))
.filter(({ q }) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
.sort((a, b) => a.sortOrder - b.sortOrder)
// Steps 2 & 3 — create questions and attach to set
// Steps 2 & 3 — create questions and attach to set (order from step 3 drag-and-drop)
let displayOrder = 0
for (const q of toCreate) {
for (const { q } of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
displayOrder += 1

View File

@ -0,0 +1,46 @@
import {
updateExamPrepPracticeFull,
updateLearnEnglishPracticeFull,
} from "../api/courses.api"
import type { PracticePublishStatus } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildPracticeFullUpdateRequest,
type PracticeFormState,
type PreservedQuestionSetFields,
type PracticeEditQuestionInput,
} from "./practiceFullMapper"
export interface PracticeEditInput {
practiceId: number
isExamPrep: boolean
status: PracticePublishStatus
formData: PracticeFormState
personaId: number
preservedQuestionSet: PreservedQuestionSetFields
questions: PracticeEditQuestionInput[]
definitions: QuestionTypeDefinition[]
isLearnEnglishLessonPractice: boolean
lessonDefaultTitle?: string
}
export async function executePracticeUpdate(
opts: PracticeEditInput,
): Promise<void> {
const payload = buildPracticeFullUpdateRequest({
formData: opts.formData,
personaId: opts.personaId,
status: opts.status,
preservedQuestionSet: opts.preservedQuestionSet,
questions: opts.questions,
definitions: opts.definitions,
isLearnEnglishLessonPractice: opts.isLearnEnglishLessonPractice,
lessonDefaultTitle: opts.lessonDefaultTitle,
})
if (opts.isExamPrep) {
await updateExamPrepPracticeFull(opts.practiceId, payload)
return
}
await updateLearnEnglishPracticeFull(opts.practiceId, payload)
}

View File

@ -0,0 +1,711 @@
import type {
GetPracticeFullResponse,
PracticeFullData,
PracticeFullPractice,
PracticeFullQuestionItem,
PracticeFullQuestionSet,
PracticePublishStatus,
QuestionOption,
QuestionShortAnswer,
UpdatePracticeFullRequest,
} from "../types/course.types"
import type {
DynamicElementInstance,
DynamicQuestionPayload,
QuestionTypeDefinition,
} from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
definitionUsesDynamicPayload,
dynamicPromptFromFieldValues,
emptyDynamicFieldValuesForDefinition,
legacyQuestionTypeFromDefinition,
questionRowHasContent,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
import { serializeMultipleChoiceSlotValue } from "./multipleChoiceSlotValue"
import { validatePracticeQuestionsWithDefinitions } from "./practiceCreationOrchestrator"
export interface PracticeFormQuestionRow {
id: string
serverQuestionId?: number | null
displayOrder: number
questionTypeDefinitionId: number | null
text: string
difficultyLevel: "EASY" | "MEDIUM" | "HARD"
points: number
dynamicFieldValues: Record<string, string>
mcqOptions: { text: string; isCorrect: boolean }[]
trueFalseCorrect: boolean
shortAnswers: string[]
}
export interface PracticeFormState {
title: string
description: string
storyImageUrl: string
shuffleQuestions: boolean
tips: string
questions: PracticeFormQuestionRow[]
}
export interface PreservedQuestionSetFields {
timeLimitMinutes: number | null
passingScore: number | null
introVideoUrl: string
status: PracticePublishStatus
}
function defaultMcqOptions() {
return [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
]
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value != null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function pickStr(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key]
if (value == null) continue
const text = String(value).trim()
if (text) return text
}
return ""
}
function pickNum(record: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = Number(record[key])
if (Number.isFinite(value)) return value
}
return null
}
function isTextPromptKind(kind: string): boolean {
const upper = kind.trim().toUpperCase()
return (
upper === "QUESTION_TEXT" ||
upper === "INSTRUCTION" ||
upper === "TEXT_PASSAGE" ||
upper === "TEXT"
)
}
function isAudioKind(kind: string): boolean {
const upper = kind.trim().toUpperCase()
return upper.includes("AUDIO") || upper.includes("VOICE")
}
function normalizeDynamicElement(raw: unknown): DynamicElementInstance | null {
const record = asRecord(raw)
if (!record) return null
const id = pickStr(record, "id", "Id", "ID")
const kind = pickStr(record, "kind", "Kind")
if (!id && !kind) return null
return {
id: id || kind.toLowerCase(),
kind,
value: record.value ?? record.Value,
}
}
function normalizeDynamicElementArray(raw: unknown): DynamicElementInstance[] {
if (!Array.isArray(raw)) return []
return raw
.map((entry) => normalizeDynamicElement(entry))
.filter((entry): entry is DynamicElementInstance => entry != null)
}
function normalizeDynamicPayload(raw: unknown): DynamicQuestionPayload | null {
const record = asRecord(raw)
if (!record) return null
return {
stimulus: normalizeDynamicElementArray(record.stimulus ?? record.Stimulus),
response: normalizeDynamicElementArray(record.response ?? record.Response),
}
}
function normalizeQuestionOptions(raw: unknown): QuestionOption[] | undefined {
if (!Array.isArray(raw)) return undefined
const options = raw
.map((entry) => {
const record = asRecord(entry)
if (!record) return null
return {
option_order:
pickNum(record, "option_order", "OptionOrder", "order", "Order") ?? 0,
option_text: pickStr(
record,
"option_text",
"OptionText",
"text",
"Text",
),
is_correct: Boolean(record.is_correct ?? record.IsCorrect),
}
})
.filter((entry): entry is QuestionOption => entry != null)
return options.length > 0 ? options : undefined
}
function normalizeShortAnswerItems(
raw: unknown,
): QuestionShortAnswer[] | string[] | undefined {
if (!Array.isArray(raw)) return undefined
const items = raw
.map((entry) => {
if (typeof entry === "string") return entry
const record = asRecord(entry)
if (!record) return null
const acceptable = pickStr(
record,
"acceptable_answer",
"AcceptableAnswer",
"answer",
"Answer",
)
if (!acceptable) return null
const matchType = pickStr(record, "match_type", "MatchType") || "CASE_INSENSITIVE"
return { acceptable_answer: acceptable, match_type: matchType }
})
.filter(
(entry): entry is QuestionShortAnswer | string =>
entry != null && (typeof entry === "string" || Boolean(entry.acceptable_answer)),
)
return items.length > 0 ? items : undefined
}
export function normalizePracticeFullQuestion(
raw: unknown,
): PracticeFullQuestionItem | null {
const record = asRecord(raw)
if (!record) return null
const id = pickNum(record, "id", "Id", "ID")
const displayOrder =
pickNum(record, "display_order", "DisplayOrder", "displayOrder") ?? 0
const questionType =
pickStr(record, "question_type", "QuestionType", "questionType") || "DYNAMIC"
return {
...(id != null ? { id } : {}),
display_order: displayOrder,
question_text: pickStr(record, "question_text", "QuestionText", "questionText") || undefined,
question_type: questionType,
question_type_definition_id:
pickNum(
record,
"question_type_definition_id",
"QuestionTypeDefinitionId",
"questionTypeDefinitionId",
) ?? null,
dynamic_payload: normalizeDynamicPayload(
record.dynamic_payload ?? record.DynamicPayload,
),
difficulty_level:
pickStr(record, "difficulty_level", "DifficultyLevel", "difficultyLevel") ||
undefined,
points: pickNum(record, "points", "Points") ?? undefined,
status:
pickStr(record, "status", "Status") ||
undefined,
options: normalizeQuestionOptions(record.options ?? record.Options),
short_answers: normalizeShortAnswerItems(
record.short_answers ?? record.ShortAnswers,
),
voice_prompt:
pickStr(record, "voice_prompt", "VoicePrompt", "voicePrompt") || undefined,
sample_answer_voice_prompt:
pickStr(
record,
"sample_answer_voice_prompt",
"SampleAnswerVoicePrompt",
"sampleAnswerVoicePrompt",
) || undefined,
audio_correct_answer_text:
pickStr(
record,
"audio_correct_answer_text",
"AudioCorrectAnswerText",
"audioCorrectAnswerText",
) || undefined,
image_url: pickStr(record, "image_url", "ImageUrl", "imageUrl") || undefined,
tips: pickStr(record, "tips", "Tips") || undefined,
explanation: pickStr(record, "explanation", "Explanation") || undefined,
created_at: pickStr(record, "created_at", "CreatedAt", "createdAt") || undefined,
}
}
function normalizePracticeFullQuestionSet(raw: unknown): PracticeFullQuestionSet | null {
const record = asRecord(raw)
if (!record) return null
const id = pickNum(record, "id", "Id", "ID")
if (id == null) return null
return {
id,
title: pickStr(record, "title", "Title"),
description:
pickStr(record, "description", "Description") ||
null,
set_type: pickStr(record, "set_type", "SetType", "setType") || undefined,
owner_type: pickStr(record, "owner_type", "OwnerType", "ownerType") || undefined,
owner_id: pickNum(record, "owner_id", "OwnerId", "ownerId") ?? undefined,
persona: pickStr(record, "persona", "Persona") || null,
shuffle_questions: Boolean(
record.shuffle_questions ?? record.ShuffleQuestions ?? false,
),
status: pickStr(record, "status", "Status") || undefined,
time_limit_minutes:
pickNum(record, "time_limit_minutes", "TimeLimitMinutes", "timeLimitMinutes"),
passing_score: pickNum(record, "passing_score", "PassingScore", "passingScore"),
intro_video_url:
pickStr(record, "intro_video_url", "IntroVideoUrl", "introVideoUrl") || null,
question_count: pickNum(record, "question_count", "QuestionCount", "questionCount") ?? undefined,
created_at: pickStr(record, "created_at", "CreatedAt", "createdAt") || undefined,
}
}
function normalizePracticeFullPractice(raw: unknown): PracticeFullPractice | null {
const record = asRecord(raw)
if (!record) return null
const id = pickNum(record, "id", "Id", "ID")
const questionSetId = pickNum(
record,
"question_set_id",
"QuestionSetId",
"questionSetId",
)
if (id == null || questionSetId == null) return null
return {
id,
title: pickStr(record, "title", "Title"),
story_description:
pickStr(record, "story_description", "StoryDescription", "storyDescription") ||
undefined,
story_image:
pickStr(record, "story_image", "StoryImage", "storyImage") || undefined,
persona_id: pickNum(record, "persona_id", "PersonaId", "personaId"),
question_set_id: questionSetId,
publish_status:
pickStr(record, "publish_status", "PublishStatus", "publishStatus") || null,
quick_tips: pickStr(record, "quick_tips", "QuickTips", "quickTips") || undefined,
lesson_id: pickNum(record, "lesson_id", "LessonId", "lessonId") ?? undefined,
parent_kind: pickStr(record, "parent_kind", "ParentKind", "parentKind") || undefined,
parent_id: pickNum(record, "parent_id", "ParentId", "parentId") ?? undefined,
created_at: pickStr(record, "created_at", "CreatedAt", "createdAt") || undefined,
}
}
export function normalizePracticeFullData(raw: unknown): PracticeFullData | null {
const record = asRecord(raw)
if (!record) return null
const practice = normalizePracticeFullPractice(record.practice ?? record.Practice)
const questionSet = normalizePracticeFullQuestionSet(
record.question_set ?? record.QuestionSet ?? record.questionSet,
)
const questionsRaw = record.questions ?? record.Questions
if (!practice || !questionSet || !Array.isArray(questionsRaw)) return null
const questions = questionsRaw
.map((entry) => normalizePracticeFullQuestion(entry))
.filter((entry): entry is PracticeFullQuestionItem => entry != null)
return { practice, question_set: questionSet, questions }
}
function payloadValuesByKind(
payload: DynamicQuestionPayload | null | undefined,
side: "stimulus" | "response",
): Map<string, unknown> {
const map = new Map<string, unknown>()
const slots = side === "stimulus" ? payload?.stimulus : payload?.response
for (const slot of slots ?? []) {
const kind = (slot.kind ?? "").trim().toUpperCase()
if (!kind || map.has(kind)) continue
map.set(kind, slot.value)
}
return map
}
function hydrateDynamicFieldValues(
def: QuestionTypeDefinition,
question: PracticeFullQuestionItem,
fieldValues: Record<string, string>,
): Record<string, string> {
const merged = { ...fieldValues }
const stimulusByKind = payloadValuesByKind(question.dynamic_payload, "stimulus")
const responseByKind = payloadValuesByKind(question.dynamic_payload, "response")
const questionText = String(question.question_text ?? "").trim()
const voicePrompt = String(question.voice_prompt ?? "").trim()
const sampleAnswerVoice = String(question.sample_answer_voice_prompt ?? "").trim()
for (const row of def.stimulus_schema) {
const key = `stimulus:${row.id}`
if (merged[key]?.trim()) continue
const kind = row.kind.trim().toUpperCase()
if (questionText && isTextPromptKind(kind)) {
merged[key] = questionText
continue
}
if (voicePrompt && isAudioKind(kind)) {
merged[key] = voicePrompt
continue
}
if (stimulusByKind.has(kind)) {
merged[key] = slotApiValueToFieldString(stimulusByKind.get(kind), row.kind)
}
}
for (const row of def.response_schema) {
const key = `response:${row.id}`
if (merged[key]?.trim()) continue
const kind = row.kind.trim().toUpperCase()
if (sampleAnswerVoice && isAudioKind(kind)) {
merged[key] = sampleAnswerVoice
continue
}
if (responseByKind.has(kind)) {
merged[key] = slotApiValueToFieldString(responseByKind.get(kind), row.kind)
}
}
return merged
}
function slotApiValueToFieldString(value: unknown, kind: string): string {
const upperKind = kind.trim().toUpperCase()
if (value == null) return ""
if (typeof value === "string") return value
if (upperKind === "PREP_TIME" || upperKind === "ANSWER_TIMER") {
if (typeof value === "object" && value !== null && "seconds" in value) {
const seconds = (value as { seconds?: unknown }).seconds
if (typeof seconds === "number" && Number.isFinite(seconds)) {
return String(seconds)
}
}
}
if (upperKind === "MULTIPLE_CHOICE" || upperKind === "OPTION") {
return serializeMultipleChoiceSlotValue(value as { options: unknown[] })
}
if (typeof value === "object") return JSON.stringify(value)
return String(value)
}
export function dynamicPayloadToFieldValues(
payload: DynamicQuestionPayload | null | undefined,
): Record<string, string> {
const out: Record<string, string> = {}
for (const slot of payload?.stimulus ?? []) {
out[`stimulus:${slot.id}`] = slotApiValueToFieldString(slot.value, slot.kind)
}
for (const slot of payload?.response ?? []) {
out[`response:${slot.id}`] = slotApiValueToFieldString(slot.value, slot.kind)
}
return out
}
function normalizeShortAnswers(
shortAnswers: PracticeFullQuestionItem["short_answers"],
): string[] {
if (!shortAnswers?.length) return [""]
const lines = shortAnswers
.map((entry) =>
typeof entry === "string" ? entry : entry.acceptable_answer,
)
.map((s) => String(s ?? "").trim())
.filter(Boolean)
return lines.length > 0 ? lines : [""]
}
function mapFullQuestionToFormRow(
rawQuestion: PracticeFullQuestionItem,
typeDefinitions: QuestionTypeDefinition[],
): PracticeFormQuestionRow {
const q = normalizePracticeFullQuestion(rawQuestion) ?? rawQuestion
const defId = q.question_type_definition_id ?? null
const def = defId
? typeDefinitions.find((d) => d.id === defId)
: undefined
let dynamicFieldValues: Record<string, string> = {}
if (def) {
dynamicFieldValues = hydrateDynamicFieldValues(def, q, {
...emptyDynamicFieldValuesForDefinition(def),
...dynamicPayloadToFieldValues(q.dynamic_payload),
})
} else if (q.dynamic_payload) {
dynamicFieldValues = dynamicPayloadToFieldValues(q.dynamic_payload)
}
let text = String(q.question_text ?? "").trim()
let mcqOptions = defaultMcqOptions()
let trueFalseCorrect = true
let shortAnswers = [""]
if (def && definitionUsesDynamicPayload(def)) {
if (!text) text = dynamicPromptFromFieldValues(def, dynamicFieldValues)
} else if (q.question_type === "MCQ" || q.question_type === "MULTIPLE_CHOICE") {
const opts = q.options ?? []
mcqOptions =
opts.length > 0
? opts.map((o) => ({
text: o.option_text ?? "",
isCorrect: Boolean(o.is_correct),
}))
: defaultMcqOptions()
} else if (q.question_type === "TRUE_FALSE") {
const correct = q.options?.find((o) => o.is_correct)
trueFalseCorrect =
correct?.option_text?.trim().toLowerCase() !== "false"
} else if (
q.question_type === "SHORT_ANSWER" ||
q.question_type === "SHORT"
) {
shortAnswers = normalizeShortAnswers(q.short_answers)
} else if (def) {
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = q.options ?? []
mcqOptions =
opts.length > 0
? opts.map((o) => ({
text: o.option_text ?? "",
isCorrect: Boolean(o.is_correct),
}))
: defaultMcqOptions()
} else if (legacy === "TRUE_FALSE") {
const correct = q.options?.find((o) => o.is_correct)
trueFalseCorrect =
correct?.option_text?.trim().toLowerCase() !== "false"
} else if (legacy === "SHORT_ANSWER") {
shortAnswers = normalizeShortAnswers(q.short_answers)
}
}
const difficulty = String(q.difficulty_level ?? "EASY").toUpperCase()
const difficultyLevel =
difficulty === "MEDIUM" || difficulty === "HARD" ? difficulty : "EASY"
return {
id: q.id != null ? `existing-${q.id}` : `q-${q.display_order}`,
serverQuestionId: q.id ?? null,
displayOrder: q.display_order,
questionTypeDefinitionId: defId,
text,
difficultyLevel,
points: Number.isFinite(Number(q.points)) && Number(q.points) > 0
? Number(q.points)
: 1,
dynamicFieldValues,
mcqOptions,
trueFalseCorrect,
shortAnswers,
}
}
export function mapPracticeFullToFormState(
data: PracticeFullData,
typeDefinitions: QuestionTypeDefinition[],
): {
formData: PracticeFormState
personaId: number | null
preservedQuestionSet: PreservedQuestionSetFields
} {
const { practice, question_set, questions } = data
const sorted = [...questions].sort(
(a, b) => (a.display_order ?? 0) - (b.display_order ?? 0),
)
const formQuestions =
sorted.length > 0
? sorted.map((q) => mapFullQuestionToFormRow(q, typeDefinitions))
: [
{
id: "q1",
displayOrder: 1,
serverQuestionId: null,
questionTypeDefinitionId: typeDefinitions[0]?.id ?? null,
text: "",
difficultyLevel: "EASY" as const,
points: 1,
dynamicFieldValues: typeDefinitions[0]
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
: {},
mcqOptions: defaultMcqOptions(),
trueFalseCorrect: true,
shortAnswers: [""],
},
]
return {
formData: {
title: practice.title?.trim() || question_set.title?.trim() || "",
description:
practice.story_description?.trim() ||
question_set.description?.trim() ||
"",
storyImageUrl: practice.story_image?.trim() || "",
shuffleQuestions: Boolean(question_set.shuffle_questions),
tips: practice.quick_tips?.trim() || "",
questions: formQuestions,
},
personaId:
practice.persona_id != null && Number.isFinite(practice.persona_id)
? practice.persona_id
: null,
preservedQuestionSet: {
timeLimitMinutes: question_set.time_limit_minutes ?? null,
passingScore: question_set.passing_score ?? null,
introVideoUrl: question_set.intro_video_url?.trim() || "",
status:
(question_set.status as PracticePublishStatus) === "DRAFT"
? "DRAFT"
: "PUBLISHED",
},
}
}
export interface PracticeEditQuestionInput extends LearnEnglishDefinitionQuestionInput {
serverQuestionId?: number | null
}
function buildFullUpdateQuestion(
def: QuestionTypeDefinition,
q: PracticeEditQuestionInput,
status: PracticePublishStatus,
displayOrder: number,
): PracticeFullQuestionItem {
const created = buildCreateQuestionFromDefinition(def, q, status)
const item: PracticeFullQuestionItem = {
display_order: displayOrder,
question_type: created.question_type,
difficulty_level: created.difficulty_level,
points: created.points,
status,
}
if (q.serverQuestionId != null && q.serverQuestionId > 0) {
item.id = q.serverQuestionId
}
if (created.question_text) item.question_text = created.question_text
if (created.question_type_definition_id != null) {
item.question_type_definition_id = created.question_type_definition_id
}
if (created.dynamic_payload) item.dynamic_payload = created.dynamic_payload
if (created.options?.length) item.options = created.options
if (created.short_answers?.length) {
item.short_answers = created.short_answers.map((entry) =>
typeof entry === "string"
? { acceptable_answer: entry, match_type: "CASE_INSENSITIVE" }
: entry,
)
}
if (created.voice_prompt) item.voice_prompt = created.voice_prompt
if (created.sample_answer_voice_prompt) {
item.sample_answer_voice_prompt = created.sample_answer_voice_prompt
}
if (created.audio_correct_answer_text) {
item.audio_correct_answer_text = created.audio_correct_answer_text
}
if (created.image_url) item.image_url = created.image_url
if (created.tips) item.tips = created.tips
if (created.explanation) item.explanation = created.explanation
return item
}
export interface BuildPracticeFullUpdateInput {
formData: PracticeFormState
personaId: number
status: PracticePublishStatus
preservedQuestionSet: PreservedQuestionSetFields
questions: PracticeEditQuestionInput[]
definitions: QuestionTypeDefinition[]
isLearnEnglishLessonPractice: boolean
lessonDefaultTitle?: string
}
export function buildPracticeFullUpdateRequest(
opts: BuildPracticeFullUpdateInput,
): UpdatePracticeFullRequest {
const err = validatePracticeQuestionsWithDefinitions(
opts.questions,
opts.definitions,
)
if (err) throw new Error(err)
const lessonTitle = opts.lessonDefaultTitle?.trim() || "Lesson practice"
const practiceTitle = opts.isLearnEnglishLessonPractice
? lessonTitle
: opts.formData.title.trim() || "Untitled practice"
const storyDescription = opts.isLearnEnglishLessonPractice
? ""
: opts.formData.description.trim()
const storyImage = opts.isLearnEnglishLessonPractice
? ""
: opts.formData.storyImageUrl.trim()
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
const toUpdate = opts.questions
.map((q, index) => ({
q,
sortOrder:
Number.isFinite(q.displayOrder) && (q.displayOrder ?? 0) > 0
? Number(q.displayOrder)
: index + 1,
}))
.filter(({ q }) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
.sort((a, b) => a.sortOrder - b.sortOrder)
let displayOrder = 0
const questions: PracticeFullQuestionItem[] = []
for (const { q } of toUpdate) {
const def = byId.get(q.questionTypeDefinitionId)
if (!def) continue
displayOrder += 1
questions.push(
buildFullUpdateQuestion(def, q, opts.status, displayOrder),
)
}
return {
practice: {
title: practiceTitle,
story_description: storyDescription,
story_image: storyImage,
persona_id: opts.personaId,
quick_tips: opts.formData.tips.trim(),
publish_status: opts.status,
},
question_set: {
title: opts.isLearnEnglishLessonPractice
? lessonTitle
: opts.formData.title.trim() || "Practice set",
description: opts.isLearnEnglishLessonPractice
? null
: opts.formData.description.trim() || null,
time_limit_minutes: opts.preservedQuestionSet.timeLimitMinutes,
passing_score: opts.preservedQuestionSet.passingScore,
shuffle_questions: opts.formData.shuffleQuestions,
status: opts.status,
intro_video_url: opts.preservedQuestionSet.introVideoUrl.trim() || null,
},
questions,
}
}
export function unwrapPracticeFullData(
res: { data?: GetPracticeFullResponse & { Data?: GetPracticeFullResponse["data"] } },
): PracticeFullData | null {
const body = res.data
if (!body) return null
const raw = body.data ?? body.Data ?? null
if (!raw) return null
return normalizePracticeFullData(raw) ?? (raw as PracticeFullData)
}

View File

@ -196,6 +196,7 @@ export function AddPracticeFlow() {
questions: [
{
id: "q1",
displayOrder: 1,
questionTypeDefinitionId: null as number | null,
text: "",
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
@ -291,7 +292,7 @@ export function AddPracticeFlow() {
return;
}
const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions.map((q) => ({
const mappedQuestions = formData.questions.map((q, index) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
difficultyLevel: (q.difficultyLevel ?? "EASY") as
@ -299,6 +300,10 @@ export function AddPracticeFlow() {
| "MEDIUM"
| "HARD",
points: Number.isFinite(Number(q.points)) ? Number(q.points) : 1,
displayOrder:
Number.isFinite(Number(q.displayOrder)) && Number(q.displayOrder) > 0
? Number(q.displayOrder)
: index + 1,
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
mcqOptions: (q.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
@ -413,6 +418,7 @@ export function AddPracticeFlow() {
questions: [
{
id: "q1",
displayOrder: 1,
questionTypeDefinitionId:
typeDefinitions[0]?.id ?? (null as number | null),
text: "",
@ -573,7 +579,7 @@ export function AddPracticeFlow() {
};
return (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
<div className="space-y-8 px-6 pb-16 pt-6">
<div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8">
<Link

View File

@ -867,7 +867,9 @@ export function CourseDetailPage() {
practice={practice}
statusUpdating={publishStatusPracticeId === practice.id}
onEdit={() =>
navigate(`/content/practices?type=course&id=${courseIdNum}`)
navigate(
`/new-content/learn-english/${programIdParam}/courses/${courseIdNum}/edit-practice/${practice.id}?backTo=courses`,
)
}
onPublish={() =>
void handlePracticePublishStatus(

View File

@ -0,0 +1,646 @@
import { useEffect, useMemo, useState } from "react";
import {
Link,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { ArrowLeft, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper";
import successIcon from "../../assets/success.svg";
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types";
import {
getExamPrepPracticeFull,
getLearnEnglishPracticeFull,
} from "../../api/courses.api";
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
import {
learnEnglishPracticeApiErrorMessage,
validateLearnEnglishQuestionsWithDefinitions,
} from "../../lib/learnEnglishPracticePublish";
import { executePracticeUpdate } from "../../lib/practiceEditOrchestrator";
import {
mapPracticeFullToFormState,
unwrapPracticeFullData,
type PreservedQuestionSetFields,
} from "../../lib/practiceFullMapper";
import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
import { ReviewStep } from "./components/practice-steps/ReviewStep";
import { personaIdNumber } from "./components/practice-steps/constants";
import { useActivePersonas } from "../../hooks/useActivePersonas";
const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
export function EditPracticeFlow() {
const navigate = useNavigate();
const {
level,
programType,
courseId: routeCourseId,
unitId: routeUnitId,
moduleId: routeModuleId,
lessonId: routeLessonId,
practiceId: routePracticeId,
} = useParams<{
level?: string;
programType?: string;
courseId?: string;
unitId?: string;
moduleId?: string;
lessonId?: string;
practiceId?: string;
}>();
const [searchParams] = useSearchParams();
const backToParam = searchParams.get("backTo");
const lessonTitleRaw = searchParams.get("lessonTitle");
const practiceId = routePracticeId ? Number(routePracticeId) : NaN;
const validPracticeId = Number.isFinite(practiceId) && practiceId > 0;
const isExamPrep = Boolean(programType?.trim());
const lessonId = routeLessonId ?? searchParams.get("lessonId");
const effectiveBackTo = useMemo(() => {
if (backToParam?.trim()) return backToParam.trim();
if (routeLessonId) return "lesson";
if (isExamPrep && routeModuleId) return "module";
if (isExamPrep && routeCourseId) return "courses";
if (routeModuleId) return "module";
if (routeCourseId) return "courses";
return null;
}, [
backToParam,
routeLessonId,
isExamPrep,
routeModuleId,
routeCourseId,
]);
const courseId = isExamPrep
? routeCourseId ?? searchParams.get("courseId")
: routeCourseId ?? searchParams.get("courseId");
const moduleId = isExamPrep
? routeModuleId ?? searchParams.get("moduleId")
: routeModuleId ?? searchParams.get("moduleId");
const unitId = isExamPrep ? routeUnitId : null;
const lessonTitleDisplay = (() => {
const raw = lessonTitleRaw?.trim();
if (!raw) return null;
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
})();
const isModuleContext = effectiveBackTo === "module";
const isCourseContext =
effectiveBackTo === "courses" || effectiveBackTo === "modules";
const isLessonContext = effectiveBackTo === "lesson" || Boolean(routeLessonId);
const isLessonPractice = useMemo(() => {
const lid = lessonId ? Number(lessonId) : NaN;
return Number.isFinite(lid) && lid > 0;
}, [lessonId]);
const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep;
const parentSummary = useMemo(() => {
if (lessonId)
return `Lesson #${lessonId}${lessonTitleDisplay ? `${lessonTitleDisplay}` : ""}`;
if (isModuleContext && moduleId) return `Module #${moduleId}`;
if (isCourseContext && courseId) return `Course #${courseId}`;
return null;
}, [
lessonId,
lessonTitleDisplay,
isModuleContext,
isCourseContext,
moduleId,
courseId,
]);
const programLabel = isExamPrep
? programType === "skill"
? "Skill-Based Courses"
: "English Proficiency Exams"
: level
? `Program ${level}`
: null;
const backLabel =
effectiveBackTo === "lesson"
? "Back to lesson practices"
: effectiveBackTo === "module"
? "Back to Module"
: effectiveBackTo === "modules"
? "Back to Modules"
: effectiveBackTo === "courses"
? "Back to Course"
: isExamPrep
? "Back to Program"
: "Back to Courses";
const backPath = useMemo(() => {
if (isExamPrep) {
if (routeLessonId && programType && courseId && unitId && moduleId) {
const title = lessonTitleRaw
? `?lessonTitle=${encodeURIComponent(lessonTitleRaw)}`
: "";
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${routeLessonId}/practices${title}`;
}
if (
effectiveBackTo === "module" &&
programType &&
courseId &&
unitId &&
moduleId
) {
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`;
}
if (effectiveBackTo === "courses" && programType && courseId) {
return `/new-content/courses/${programType}/${courseId}`;
}
if (programType) return `/new-content/courses/${programType}`;
return "/new-content";
}
if (routeLessonId && level && courseId && moduleId) {
const title = lessonTitleRaw
? `?lessonTitle=${encodeURIComponent(lessonTitleRaw)}`
: "";
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${routeLessonId}/practices${title}`;
}
if (effectiveBackTo === "module" && level && courseId && moduleId) {
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
}
if (effectiveBackTo === "courses" && level && courseId) {
return `/new-content/learn-english/${level}/courses/${courseId}`;
}
if (level) return `/new-content/learn-english/${level}/courses`;
return "/new-content";
}, [
isExamPrep,
routeLessonId,
programType,
courseId,
unitId,
moduleId,
lessonTitleRaw,
effectiveBackTo,
level,
]);
const [currentStep, setCurrentStep] = useState(1);
const [isSaved, setIsSaved] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loadingPractice, setLoadingPractice] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const [preservedQuestionSet, setPreservedQuestionSet] =
useState<PreservedQuestionSetFields>({
timeLimitMinutes: null,
passingScore: null,
introVideoUrl: "",
status: "PUBLISHED",
});
const {
personas,
loading: personasLoading,
error: personasError,
reload: reloadPersonas,
} = useActivePersonas();
const [formData, setFormData] = useState({
title: "",
description: "",
storyImageUrl: "",
shuffleQuestions: false,
tips: "",
questions: [
{
id: "q1",
displayOrder: 1,
serverQuestionId: null as number | null,
questionTypeDefinitionId: null as number | null,
text: "",
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
points: 1,
dynamicFieldValues: {} as Record<string, string>,
mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
},
],
});
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>(
[],
);
const [definitionsLoading, setDefinitionsLoading] = useState(true);
const [definitionsError, setDefinitionsError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setDefinitionsLoading(true);
setDefinitionsError(null);
try {
const { definitions: list } = await getQuestionTypeDefinitions({
include_system: true,
status: "ACTIVE",
});
if (!cancelled) setTypeDefinitions(list);
} catch (e) {
if (!cancelled) {
setDefinitionsError(learnEnglishPracticeApiErrorMessage(e));
setTypeDefinitions([]);
}
} finally {
if (!cancelled) setDefinitionsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!validPracticeId || definitionsLoading) return;
let cancelled = false;
(async () => {
setLoadingPractice(true);
setLoadError(null);
try {
const res = isExamPrep
? await getExamPrepPracticeFull(practiceId)
: await getLearnEnglishPracticeFull(practiceId);
const full = unwrapPracticeFullData(res);
if (!full) throw new Error("Practice details were missing from the response.");
const mapped = mapPracticeFullToFormState(full, typeDefinitions);
if (cancelled) return;
setFormData(mapped.formData);
setPreservedQuestionSet(mapped.preservedQuestionSet);
if (mapped.personaId != null) {
setSelectedPersona(String(mapped.personaId));
}
} catch (e) {
if (!cancelled) {
setLoadError(learnEnglishPracticeApiErrorMessage(e));
}
} finally {
if (!cancelled) setLoadingPractice(false);
}
})();
return () => {
cancelled = true;
};
}, [validPracticeId, practiceId, isExamPrep, typeDefinitions, definitionsLoading]);
const submitPractice = async (status: "DRAFT" | "PUBLISHED") => {
if (!validPracticeId) {
toast.error("Invalid practice", { description: "Missing practice id in the URL." });
return;
}
if (
!isLearnEnglishLessonPractice &&
(!formData.title.trim() || !formData.description.trim())
) {
toast.error("Title and story description are required", {
description: "Complete the first step before saving.",
});
return;
}
if (!selectedPersona) {
toast.error("Select a persona", {
description: "Choose a character on the Persona step before saving.",
});
return;
}
const personaId = personaIdNumber(selectedPersona);
if (!personaId) {
toast.error("Invalid persona", {
description: "Re-select a persona from the list and try again.",
});
return;
}
const mappedQuestions = formData.questions.map((q, index) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
difficultyLevel: (q.difficultyLevel ?? "EASY") as "EASY" | "MEDIUM" | "HARD",
points: Number.isFinite(Number(q.points)) ? Number(q.points) : 1,
displayOrder:
Number.isFinite(Number(q.displayOrder)) && Number(q.displayOrder) > 0
? Number(q.displayOrder)
: index + 1,
serverQuestionId: q.serverQuestionId ?? null,
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
mcqOptions: (q.mcqOptions ?? []).map(
(o: { text?: string; isCorrect?: boolean }) => ({
option_text: String(o.text ?? ""),
is_correct: Boolean(o.isCorrect),
}),
),
trueFalseAnswerIsTrue: q.trueFalseCorrect !== false,
shortAnswers: (q.shortAnswers ?? []).map((s: string) => String(s)),
}));
const validationMsg = validateLearnEnglishQuestionsWithDefinitions(
mappedQuestions,
typeDefinitions,
);
if (validationMsg) {
toast.error("Check your questions", { description: validationMsg });
return;
}
const lessonDefaultTitle =
lessonTitleDisplay?.trim() ||
(lessonId ? `Lesson ${lessonId} practice` : "Lesson practice");
setSubmitting(true);
try {
await executePracticeUpdate({
practiceId,
isExamPrep,
status,
formData,
personaId,
preservedQuestionSet: {
...preservedQuestionSet,
status,
},
questions: mappedQuestions,
definitions: typeDefinitions,
isLearnEnglishLessonPractice,
lessonDefaultTitle,
});
toast.success(
status === "PUBLISHED" ? "Practice updated and published" : "Practice saved as draft",
);
setIsSaved(true);
} catch (e) {
toast.error("Could not update practice", {
description: learnEnglishPracticeApiErrorMessage(e),
});
} finally {
setSubmitting(false);
}
};
const nextStep = () =>
setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (!validPracticeId) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<p className="text-lg font-semibold text-grayScale-800">Invalid practice link</p>
<Button className="mt-6" variant="outline" onClick={() => navigate(-1)}>
Go back
</Button>
</div>
);
}
if (loadingPractice || definitionsLoading) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
<Loader2 className="h-10 w-10 animate-spin text-brand-500" />
<p className="text-sm font-medium text-grayScale-600">Loading practice details</p>
</div>
);
}
if (loadError) {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-6 text-center">
<p className="text-lg font-semibold text-grayScale-800">Could not load practice</p>
<p className="mt-2 max-w-md text-sm text-grayScale-600">{loadError}</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" onClick={() => navigate(backPath)}>
{backLabel}
</Button>
<Button onClick={() => window.location.reload()}>Try again</Button>
</div>
</div>
);
}
if (isSaved) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center pb-20 animate-in fade-in zoom-in duration-500">
<div className="mb-10 relative">
<div className="absolute inset-0 bg-brand-500/10 blur-3xl rounded-full" />
<img
src={successIcon}
alt="Success"
className="h-[128px] w-[128px] relative"
/>
</div>
<h1 className="text-[28px] font-bold text-grayScale-900 mb-2">
Practice Updated Successfully!
</h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
Your changes to this practice have been saved.
</p>
<Button
onClick={() => navigate(backPath)}
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white w-full max-w-[400px]"
>
{backLabel}
</Button>
</div>
);
}
const renderStep = () => {
const useContextStep =
isModuleContext || isCourseContext || isLessonContext;
if (useContextStep) {
switch (currentStep) {
case 1:
return (
<ContextStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
onCancel={() => navigate(backPath)}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
parentSummary={parentSummary}
/>
);
case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 4:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={programLabel}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep}
onEditContext={() => setCurrentStep(1)}
onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
publishLabel="Publish Updates"
publishingLabel="Publishing updates…"
/>
);
default:
return null;
}
}
switch (currentStep) {
case 1:
return (
<ScenarioStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
cancelHref={backPath}
/>
);
case 2:
return (
<PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/>
);
case 4:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={programLabel}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep}
onEditContext={() => setCurrentStep(1)}
onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
publishLabel="Publish Updates"
publishingLabel="Publishing updates…"
/>
);
default:
return null;
}
};
return (
<div className="space-y-8 px-6 pb-16 pt-6">
<div className="mx-auto max-w-7xl w-full">
<div className="flex items-center justify-between mb-8">
<Link
to={backPath}
className="flex items-center gap-2 text-[15px] font-medium text-grayScale-600 transition-colors hover:text-brand-500 decoration-none"
>
<ArrowLeft className="h-4 w-4" />
{backLabel}
</Link>
</div>
<div className="mb-10">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-[#0F172A]">Edit Practice</h1>
<Button
variant="outline"
className="rounded-[8px] border-grayScale-200 text-grayScale-600 h-10 px-6 font-bold bg-white hover:bg-grayScale-50"
onClick={() => navigate(backPath)}
>
Cancel
</Button>
</div>
<p className="text-grayScale-400 text-base">
Update story details, persona, and questions for practice #{practiceId}.
</p>
</div>
<div className="mx-auto w-[70%] mb-12">
<Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
</div>
<div
className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
>
{renderStep()}
</div>
</div>
</div>
);
}

View File

@ -5,6 +5,7 @@ import {
BookOpen,
Calendar,
Clock,
Edit2,
Hash,
Loader2,
RefreshCw,
@ -91,6 +92,7 @@ function PracticeCard({
practice,
index,
total,
onEdit,
onDelete,
onTogglePublishStatus,
publishStatusUpdating,
@ -98,6 +100,7 @@ function PracticeCard({
practice: ParentContextPractice;
index: number;
total: number;
onEdit?: () => void;
onDelete?: () => void;
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
publishStatusUpdating?: boolean;
@ -201,7 +204,7 @@ function PracticeCard({
</div>
) : null}
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
<div className="mt-6 flex flex-wrap items-center gap-2 border-t border-grayScale-100 pt-5">
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Hash className="h-3 w-3 opacity-70" aria-hidden />
Question set {practice.question_set_id}
@ -210,6 +213,18 @@ function PracticeCard({
<Clock className="h-3 w-3 opacity-70" aria-hidden />
{formatPracticeDate(practice.created_at)}
</Badge>
{onEdit ? (
<Button
type="button"
variant="outline"
size="sm"
className="ml-auto h-9 rounded-[10px] border-brand-500 text-xs font-bold text-brand-500 hover:bg-brand-50"
onClick={onEdit}
>
<Edit2 className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
) : null}
</div>
</div>
</div>
@ -321,6 +336,16 @@ export function LessonPracticesPage() {
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
const editPracticeHref = (practiceId: number) => {
const titleQuery = lessonTitle
? `lessonTitle=${encodeURIComponent(lessonTitle)}&`
: "";
if (isExamPrep) {
return `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${lid}/edit-practice/${practiceId}?${titleQuery}backTo=lesson`;
}
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lid}/edit-practice/${practiceId}?${titleQuery}backTo=lesson`;
};
const handlePracticePublishStatus = async (
practiceId: number,
nextStatus: PracticePublishStatus,
@ -547,6 +572,7 @@ export function LessonPracticesPage() {
practice={p}
index={i}
total={filteredPractices.length}
onEdit={() => void navigate(editPracticeHref(p.id))}
onDelete={
isExamPrep ? () => setPracticeToDelete(p) : undefined
}

View File

@ -657,7 +657,7 @@ export function ModuleDetailPage() {
statusUpdating={publishStatusPracticeId === practice.id}
onEdit={() =>
navigate(
`/content/practices?type=module&id=${moduleId}`,
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/edit-practice/${practice.id}?backTo=module`,
)
}
onPublish={() =>

View File

@ -35,6 +35,8 @@ export type PracticeSequentialReviewProps = {
onBack: () => void;
onSaveDraft: () => void;
onPublish: () => void;
publishLabel?: string;
publishingLabel?: string;
sectionTitle?: string;
sectionSubtitle?: string;
};
@ -136,6 +138,8 @@ export function PracticeSequentialReview({
onBack,
onSaveDraft,
onPublish,
publishLabel = "Publish Now",
publishingLabel = "Publishing…",
sectionTitle = "Create Practice Questions",
sectionSubtitle = "Define the dialogue flow and interactions for this scenario.",
}: PracticeSequentialReviewProps) {
@ -280,7 +284,7 @@ export function PracticeSequentialReview({
{filledQuestions.length}
</span>
</div>
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto overscroll-y-contain px-6 py-5">
{filledQuestions.map((question, index) => (
<div key={question.id} className="space-y-3">
<span className="text-sm font-bold text-grayScale-400">
@ -329,7 +333,7 @@ export function PracticeSequentialReview({
</button>
) : null}
</div>
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto px-6 py-5">
<div className="max-h-[min(70vh,40rem)] space-y-6 overflow-y-auto overscroll-y-contain px-6 py-5">
{filledQuestions.map((question, index) => (
<div key={question.id} className="space-y-3">
<span className="text-sm font-bold text-grayScale-400">
@ -398,7 +402,7 @@ export function PracticeSequentialReview({
) : (
<Rocket className="h-4 w-4" />
)}
{saving ? "Publishing…" : "Publish Now"}
{saving ? publishingLabel : publishLabel}
</Button>
</div>
</div>

View File

@ -1,4 +1,31 @@
import { Trash2, Plus, ArrowRight } from "lucide-react";
import { useState } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Trash2,
Plus,
ArrowRight,
GripVertical,
ChevronDown,
ChevronsDownUp,
ChevronsUpDown,
} from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { Card } from "../../../../components/ui/card";
import { Input } from "../../../../components/ui/input";
@ -11,8 +38,111 @@ import {
legacyQuestionTypeFromDefinition,
} from "../../../../lib/learnEnglishDefinitionQuestion";
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
import { cn } from "../../../../lib/utils";
import { toast } from "sonner";
function syncQuestionDisplayOrders<T extends { displayOrder?: number }>(
questions: T[],
): T[] {
return questions.map((q, index) => ({ ...q, displayOrder: index + 1 }));
}
function truncateText(value: string, max = 100): string {
const trimmed = value.trim();
return trimmed.length > max ? `${trimmed.slice(0, max)}` : trimmed;
}
function questionSummaryPreview(
q: {
text?: string;
dynamicFieldValues?: Record<string, string>;
questionTypeDefinitionId?: number | null;
difficultyLevel?: string;
points?: number;
},
def: QuestionTypeDefinition | undefined,
): string {
const text = String(q.text ?? "").trim();
if (text) return truncateText(text);
const values = Object.values(q.dynamicFieldValues ?? {})
.map((v) => String(v ?? "").trim())
.filter((v) => v && !v.startsWith("{") && !v.startsWith("["));
if (values[0]) return truncateText(values[0]);
if (def) return questionTypeDefinitionListLabel(def);
return "No content yet";
}
function QuestionCollapsibleBody({
expanded,
children,
}: {
expanded: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-in-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
"border-t border-grayScale-50 transition-opacity duration-300",
expanded ? "opacity-100" : "opacity-0",
)}
>
{children}
</div>
</div>
</div>
);
}
interface SortableQuestionCardProps {
id: string;
children: (opts: {
dragHandleProps: React.HTMLAttributes<HTMLButtonElement>;
isDragging: boolean;
}) => React.ReactNode;
}
function SortableQuestionCard({ id, children }: SortableQuestionCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(isDragging && "relative z-50 opacity-60")}
>
{children({
isDragging,
dragHandleProps: {
...attributes,
...listeners,
type: "button",
},
})}
</div>
);
}
function defaultMcqOptions() {
return [
{ text: "", isCorrect: true },
@ -22,9 +152,11 @@ function defaultMcqOptions() {
];
}
function createEmptyQuestionRow(id: string) {
function createEmptyQuestionRow(id: string, displayOrder = 1) {
return {
id,
displayOrder,
serverQuestionId: null as number | null,
questionTypeDefinitionId: null as number | null,
text: "",
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
@ -55,6 +187,65 @@ export function QuestionsStep({
definitionsLoading,
definitionsError,
}: QuestionsStepProps) {
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [expandedQuestionIds, setExpandedQuestionIds] = useState<Set<string>>(
() => new Set(formData.questions[0]?.id ? [formData.questions[0].id] : []),
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const questionIds = formData.questions.map((q: { id: string }) => q.id);
const canReorder = formData.questions.length > 1;
const activeDragIndex = activeDragId
? formData.questions.findIndex((q: { id: string }) => q.id === activeDragId)
: -1;
const reorderQuestions = (activeId: string, overId: string) => {
const oldIndex = formData.questions.findIndex(
(q: { id: string }) => q.id === activeId,
);
const newIndex = formData.questions.findIndex(
(q: { id: string }) => q.id === overId,
);
if (oldIndex === -1 || newIndex === -1) return;
setFormData({
...formData,
questions: syncQuestionDisplayOrders(
arrayMove(formData.questions, oldIndex, newIndex),
),
});
};
const toggleQuestionExpanded = (id: string) => {
setExpandedQuestionIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const collapseAllQuestions = () => setExpandedQuestionIds(new Set());
const expandAllQuestions = () =>
setExpandedQuestionIds(new Set(questionIds));
const handleDragStart = (event: DragStartEvent) => {
setActiveDragId(String(event.active.id));
collapseAllQuestions();
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
reorderQuestions(String(active.id), String(over.id));
}
setActiveDragId(null);
};
const applyDefinitionToQuestion = (
index: number,
definitionId: number,
@ -93,8 +284,9 @@ export function QuestionsStep({
}
setFormData({
...formData,
questions: [...formData.questions, row],
questions: syncQuestionDisplayOrders([...formData.questions, row]),
});
setExpandedQuestionIds(new Set([id]));
};
const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
@ -319,11 +511,36 @@ export function QuestionsStep({
<div className="space-y-1 px-2">
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
<p className="text-grayScale-400 text-lg">
Choose a question type for each item, then fill in the fields that type requires. Questions are saved
when you publish or save the practice.
Choose a question type for each item, then fill in the fields that type requires. Collapse cards to
compare and drag them into order. Questions are saved when you publish or save the practice.
</p>
</div>
{formData.questions.length > 1 ? (
<div className="flex flex-wrap items-center justify-end gap-2 px-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2 rounded-lg border-grayScale-200 text-grayScale-700"
onClick={collapseAllQuestions}
>
<ChevronsDownUp className="h-4 w-4" />
Collapse all
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2 rounded-lg border-grayScale-200 text-grayScale-700"
onClick={expandAllQuestions}
>
<ChevronsUpDown className="h-4 w-4" />
Expand all
</Button>
</div>
) : null}
{definitionsError ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{definitionsError}
@ -334,50 +551,134 @@ export function QuestionsStep({
<p className="px-2 text-sm text-grayScale-500">Loading question types</p>
) : null}
<div className="space-y-6">
{formData.questions.map((q: any, i: number) => {
const def = typeDefinitions.find(
(d) => d.id === q.questionTypeDefinitionId,
);
return (
<Card
key={q.id}
className="relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft"
>
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
<div className="space-y-6 px-5 pb-7 pt-4 pl-7">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-50 pb-4">
<span className="text-base font-bold text-grayScale-500">
Question {i + 1}
</span>
<Button
variant="ghost"
size="icon"
type="button"
className="text-brand-500 hover:bg-brand-50 rounded-lg"
onClick={() => {
const newQuestions = formData.questions.filter(
(item: any) => item.id !== q.id,
);
if (newQuestions.length > 0) {
setFormData({ ...formData, questions: newQuestions });
return;
}
const row = createEmptyQuestionRow("q1");
if (typeDefinitions[0]) {
row.questionTypeDefinitionId = typeDefinitions[0].id;
row.dynamicFieldValues =
emptyDynamicFieldValuesForDefinition(
typeDefinitions[0],
);
}
setFormData({ ...formData, questions: [row] });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={questionIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{formData.questions.map((q: any, i: number) => {
const def = typeDefinitions.find(
(d) => d.id === q.questionTypeDefinitionId,
);
const isExpanded = expandedQuestionIds.has(q.id);
const summary = questionSummaryPreview(q, def);
const typeLabel = def
? questionTypeDefinitionListLabel(def)
: "No type selected";
return (
<SortableQuestionCard key={q.id} id={q.id}>
{({ dragHandleProps, isDragging }) => (
<Card
className={cn(
"relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft transition-shadow duration-300",
isDragging && "shadow-lg ring-2 ring-brand-200",
!isExpanded && "hover:border-grayScale-200",
)}
>
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
<div className="pl-7">
<div
className={cn(
"flex items-start gap-2 px-4 py-3 sm:px-5",
isExpanded ? "pb-1" : "pb-3",
)}
>
{canReorder ? (
<button
{...dragHandleProps}
className="mt-0.5 shrink-0 cursor-grab touch-none rounded-lg p-1 text-grayScale-400 transition-colors hover:bg-grayScale-50 hover:text-grayScale-600 active:cursor-grabbing"
aria-label={`Drag to reorder question ${i + 1}`}
>
<GripVertical className="h-5 w-5" />
</button>
) : null}
<button
type="button"
className="flex min-w-0 flex-1 items-start gap-3 text-left"
onClick={() => toggleQuestionExpanded(q.id)}
aria-expanded={isExpanded}
>
<ChevronDown
className={cn(
"mt-0.5 h-5 w-5 shrink-0 text-grayScale-400 transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-base font-bold text-grayScale-700">
Question {q.displayOrder ?? i + 1}
</span>
<span className="rounded-full bg-grayScale-100 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-grayScale-600">
{q.difficultyLevel ?? "EASY"}
</span>
<span className="text-xs font-medium text-grayScale-500">
{q.points ?? 1} pt{(q.points ?? 1) === 1 ? "" : "s"}
</span>
</div>
<p className="text-xs font-medium text-brand-600">
{typeLabel}
</p>
<p
className={cn(
"text-sm text-grayScale-500 transition-all duration-300",
isExpanded
? "line-clamp-1 opacity-80"
: "line-clamp-2",
)}
>
{summary}
</p>
</div>
</button>
<Button
variant="ghost"
size="icon"
type="button"
className="shrink-0 text-brand-500 hover:bg-brand-50 rounded-lg"
onClick={() => {
const newQuestions = formData.questions.filter(
(item: any) => item.id !== q.id,
);
setExpandedQuestionIds((prev) => {
const next = new Set(prev);
next.delete(q.id);
return next;
});
if (newQuestions.length > 0) {
setFormData({
...formData,
questions: syncQuestionDisplayOrders(newQuestions),
});
return;
}
const row = createEmptyQuestionRow("q1");
if (typeDefinitions[0]) {
row.questionTypeDefinitionId = typeDefinitions[0].id;
row.dynamicFieldValues =
emptyDynamicFieldValuesForDefinition(
typeDefinitions[0],
);
}
setFormData({
...formData,
questions: syncQuestionDisplayOrders([row]),
});
setExpandedQuestionIds(new Set(["q1"]));
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<QuestionCollapsibleBody expanded={isExpanded}>
<div className="space-y-6 px-4 pb-6 pt-4 sm:px-5 sm:pb-7">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
@ -484,25 +785,63 @@ export function QuestionsStep({
</p>
) : null}
{def ? renderTypeSpecificFields(q, i, def) : null}
</div>
{def ? renderTypeSpecificFields(q, i, def) : null}
</div>
</QuestionCollapsibleBody>
</div>
</Card>
)}
</SortableQuestionCard>
);
})}
</div>
</SortableContext>
<DragOverlay>
{activeDragId && activeDragIndex >= 0 ? (
<Card className="relative w-[min(100vw-2rem,42rem)] overflow-hidden rounded-2xl border border-brand-300 bg-white py-3 pl-7 pr-4 shadow-xl">
<div className="absolute bottom-0 left-0 top-0 w-[5px] bg-brand-500" />
{(() => {
const dragged = formData.questions[activeDragIndex];
const draggedDef = typeDefinitions.find(
(d) => d.id === dragged?.questionTypeDefinitionId,
);
return (
<div className="flex items-start gap-2 pl-4">
<GripVertical className="mt-0.5 h-5 w-5 shrink-0 text-grayScale-400" />
<div className="min-w-0 space-y-1">
<p className="text-base font-bold text-grayScale-700">
Question{" "}
{dragged?.displayOrder ?? activeDragIndex + 1}
</p>
<p className="text-xs font-medium text-brand-600">
{draggedDef
? questionTypeDefinitionListLabel(draggedDef)
: "No type selected"}
</p>
<p className="line-clamp-2 text-sm text-grayScale-500">
{questionSummaryPreview(dragged, draggedDef)}
</p>
</div>
</div>
);
})()}
</Card>
);
})}
) : null}
</DragOverlay>
</DndContext>
<div className="flex items-center gap-8 pt-4">
<button
type="button"
onClick={addQuestion}
disabled={definitionsLoading || typeDefinitions.length === 0}
className="flex items-center gap-3 text-base font-bold text-brand-500 transition-all hover:opacity-80 disabled:opacity-40"
>
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" />
</div>
Add question
</button>
</div>
<div className="flex items-center gap-8 pt-4">
<button
type="button"
onClick={addQuestion}
disabled={definitionsLoading || typeDefinitions.length === 0}
className="flex items-center gap-3 text-base font-bold text-brand-500 transition-all hover:opacity-80 disabled:opacity-40"
>
<div className="flex h-5 w-5 items-center justify-center rounded-full border-2 border-brand-500">
<Plus className="h-3 w-3 stroke-[4]" />
</div>
Add question
</button>
</div>
<div className="flex items-center justify-between pt-8">

View File

@ -38,6 +38,8 @@ interface ReviewStepProps {
submitting: boolean;
onSaveDraft: () => void;
onPublish: () => void;
publishLabel?: string;
publishingLabel?: string;
}
export function ReviewStep({
@ -58,6 +60,8 @@ export function ReviewStep({
submitting,
onSaveDraft,
onPublish,
publishLabel,
publishingLabel,
}: ReviewStepProps) {
const persona = personaFromId(selectedPersona, personas);
@ -114,6 +118,8 @@ export function ReviewStep({
onBack={prevStep}
onSaveDraft={onSaveDraft}
onPublish={onPublish}
publishLabel={publishLabel}
publishingLabel={publishingLabel}
/>
);
}

View File

@ -1,71 +1,254 @@
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
import { Link, useNavigate } from "react-router-dom"
import { Bell, CalendarClock, Mail, MailOpen, Megaphone, Search, Smartphone } from "lucide-react"
import { toast } from "sonner"
import { Card, CardContent } from "../../components/ui/card"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { FileUpload } from "../../components/ui/file-upload"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { NotificationSchedulePicker } from "../../components/notifications/NotificationSchedulePicker"
import { cn } from "../../lib/utils"
import { getTeamMembers } from "../../api/team.api"
import type { TeamMember } from "../../types/team.types"
import {
sendBulkEmail,
sendBulkInApp,
sendBulkPush,
sendBulkSms,
} from "../../api/notifications.api"
import {
extractApiErrorMessage,
fetchAllPlatformUsers,
formatScheduledAtLabel,
IN_APP_LEVELS,
IN_APP_TYPES,
parseDirectRecipients,
PLATFORM_ROLES,
} from "../../lib/notificationBulk"
import type {
InAppNotificationLevel,
NotificationChannel,
PlatformRole,
} from "../../types/notification.types"
import type { UserApiDTO } from "../../types/user.types"
type AudienceMode = "role" | "selected" | "direct"
type SendMode = "now" | "schedule"
const CHANNELS: {
value: NotificationChannel
label: string
icon: typeof Bell
}[] = [
{ value: "push", label: "Push", icon: Bell },
{ value: "sms", label: "SMS", icon: Mail },
{ value: "email", label: "Email", icon: MailOpen },
{ value: "in_app", label: "In-app", icon: Smartphone },
]
export function CreateNotificationPage() {
const navigate = useNavigate()
const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"])
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
const [channel, setChannel] = useState<NotificationChannel>("push")
const [audienceMode, setAudienceMode] = useState<AudienceMode>("role")
const [sendMode, setSendMode] = useState<SendMode>("now")
const [platformRole, setPlatformRole] = useState<PlatformRole>("STUDENT")
const [users, setUsers] = useState<UserApiDTO[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false)
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
const [composeTitle, setComposeTitle] = useState("")
const [composeMessage, setComposeMessage] = useState("")
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([])
const [userSearchQuery, setUserSearchQuery] = useState("")
const [directRecipients, setDirectRecipients] = useState("")
const [title, setTitle] = useState("")
const [message, setMessage] = useState("")
const [htmlBody, setHtmlBody] = useState("")
const [inAppType, setInAppType] = useState("system_alert")
const [inAppLevel, setInAppLevel] = useState<InAppNotificationLevel>("info")
const [scheduledAt, setScheduledAt] = useState("")
const [attachment, setAttachment] = useState<File | null>(null)
const [sending, setSending] = useState(false)
const [, setComposeImage] = useState<File | null>(null)
useEffect(() => {
setRecipientsLoading(true)
getTeamMembers(1, 50)
.then((res) => {
setTeamRecipients(res.data.data ?? [])
})
.catch(() => {
setTeamRecipients([])
})
.finally(() => {
setRecipientsLoading(false)
})
fetchAllPlatformUsers()
.then(setUsers)
.catch(() => setUsers([]))
.finally(() => setRecipientsLoading(false))
}, [])
const selectedRecipients = useMemo(
() => teamRecipients.filter((m) => selectedRecipientIds.includes(m.id)),
[teamRecipients, selectedRecipientIds],
const filteredUsers = useMemo(() => {
const q = userSearchQuery.trim().toLowerCase()
if (!q) return users
return users.filter((user) => {
const fullName = `${user.first_name ?? ""} ${user.last_name ?? ""}`.trim().toLowerCase()
const email = (user.email ?? "").toLowerCase()
const phone = (user.phone_number ?? "").toLowerCase()
return fullName.includes(q) || email.includes(q) || phone.includes(q)
})
}, [users, userSearchQuery])
const selectedUsers = useMemo(
() => users.filter((u) => selectedUserIds.includes(u.id)),
[users, selectedUserIds],
)
const filteredSelectedCount = useMemo(
() => filteredUsers.filter((u) => selectedUserIds.includes(u.id)).length,
[filteredUsers, selectedUserIds],
)
const needsTitle = channel === "push" || channel === "email" || channel === "in_app"
const titleLabel =
channel === "email" ? "Subject" : channel === "sms" ? "Title (optional)" : "Title"
const supportsDirect = channel === "sms" || channel === "email"
const supportsAttachment =
(channel === "email" || channel === "push") && sendMode === "now"
const isScheduling = sendMode === "schedule"
const resetForm = () => {
setTitle("")
setMessage("")
setHtmlBody("")
setAudienceMode("role")
setPlatformRole("STUDENT")
setSelectedUserIds([])
setUserSearchQuery("")
setDirectRecipients("")
setScheduledAt("")
setAttachment(null)
setSendMode("now")
setChannel("push")
setInAppType("system_alert")
setInAppLevel("info")
}
const validateTargeting = (): boolean => {
if (audienceMode === "role") return true
if (audienceMode === "selected") {
if (selectedUserIds.length === 0) {
toast.error("Select at least one user")
return false
}
return true
}
const direct = parseDirectRecipients(directRecipients)
if (direct.length === 0) {
toast.error(channel === "sms" ? "Enter at least one phone number" : "Enter at least one email")
return false
}
return true
}
const buildTargeting = () => {
if (audienceMode === "role") {
return { role: platformRole }
}
if (audienceMode === "selected") {
return { user_ids: selectedUserIds }
}
const direct = parseDirectRecipients(directRecipients)
if (channel === "sms") return { phone_numbers: direct }
return { emails: direct }
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!composeTitle.trim() || !composeMessage.trim()) return
if (composeChannels.length === 0) return
setSending(true)
if (channel !== "sms" && !title.trim()) {
toast.error(channel === "email" ? "Subject is required" : "Title is required")
return
}
if (!message.trim() && !(channel === "email" && htmlBody.trim())) {
toast.error("Message is required")
return
}
if (isScheduling && !scheduledAt) {
toast.error("Choose a schedule date and time")
return
}
if (!validateTargeting()) return
const targeting = buildTargeting()
const schedule = isScheduling && scheduledAt ? { scheduled_at: scheduledAt } : {}
try {
// Hook up to backend send API here when available.
await new Promise((resolve) => setTimeout(resolve, 400))
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
navigate("/notifications")
setSending(true)
let result
if (channel === "sms") {
result = await sendBulkSms({
message: message.trim(),
...targeting,
...schedule,
} as Parameters<typeof sendBulkSms>[0])
} else if (channel === "email") {
const form = new FormData()
form.append("subject", title.trim())
if (message.trim()) form.append("message", message.trim())
if (htmlBody.trim()) form.append("html", htmlBody.trim())
if ("role" in targeting) form.append("role", targeting.role)
if ("user_ids" in targeting) {
form.append("user_ids", JSON.stringify(targeting.user_ids))
}
if ("emails" in targeting) {
form.append("emails", JSON.stringify(targeting.emails))
}
if (isScheduling) form.append("scheduled_at", scheduledAt)
if (attachment) form.append("file", attachment)
result = await sendBulkEmail(form)
} else if (channel === "push") {
const form = new FormData()
form.append("title", title.trim())
form.append("message", message.trim())
if ("role" in targeting) form.append("role", targeting.role)
if ("user_ids" in targeting) {
form.append("user_ids", JSON.stringify(targeting.user_ids))
}
if (isScheduling) form.append("scheduled_at", scheduledAt)
if (attachment) form.append("file", attachment)
result = await sendBulkPush(form)
} else {
result = await sendBulkInApp({
title: title.trim(),
message: message.trim(),
type: inAppType,
level: inAppLevel,
...targeting,
...schedule,
} as Parameters<typeof sendBulkInApp>[0])
}
if (result.kind === "scheduled") {
toast.success("Notification scheduled", {
description: `Job #${result.data.id} · ${new Date(result.data.scheduled_at).toLocaleString()}`,
action: {
label: "View scheduled",
onClick: () => navigate("/notifications/scheduled"),
},
})
} else {
const { data } = result
const total =
data.total_recipients ?? data.target_users ?? data.sent + data.failed
toast.success("Notification sent", {
description: `${data.sent} sent · ${data.failed} failed · ${total} recipient${total === 1 ? "" : "s"}`,
})
}
resetForm()
} catch (err) {
toast.error("Failed to send notification", {
description: extractApiErrorMessage(err, "Please try again."),
})
} finally {
setSending(false)
}
}
const previewIcon = CHANNELS.find((c) => c.value === channel)?.icon ?? Bell
return (
<div className="mx-auto w-full max-w-5xl space-y-5">
{/* Breadcrumb + Header */}
<div className="space-y-2">
<nav className="flex items-center gap-1 text-xs text-grayScale-400">
<button
@ -84,7 +267,7 @@ export function CreateNotificationPage() {
Notifications
</button>
<span>/</span>
<span className="text-grayScale-500">Create</span>
<span className="text-grayScale-500">Send</span>
</nav>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@ -93,25 +276,29 @@ export function CreateNotificationPage() {
Notifications
</p>
<h1 className="mt-1 flex items-center gap-2 text-2xl font-semibold tracking-tight text-grayScale-700">
Create notification
Send notification
<span className="inline-flex h-7 items-center gap-1 rounded-full bg-brand-500/90 px-2 text-[11px] font-medium text-white">
<Megaphone className="h-3.5 w-3.5" />
Composer
</span>
</h1>
<p className="mt-1 text-xs text-grayScale-400">
Send a one-off push or SMS notification to your users.
Send or schedule bulk SMS, email, push, or in-app notifications.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => navigate("/notifications")}
>
Back to notifications
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link to="/notifications/scheduled">Scheduled jobs</Link>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate("/notifications")}
>
Back to inbox
</Button>
</div>
</div>
</div>
@ -119,103 +306,106 @@ export function CreateNotificationPage() {
onSubmit={handleSubmit}
className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1.1fr)]"
>
{/* Left: message setup */}
<div className="space-y-4">
<Card className="shadow-none border border-grayScale-100">
<Card className="border border-grayScale-100 shadow-none">
<CardContent className="space-y-4 p-4">
{/* Channel & audience */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Channel
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() =>
setComposeChannels((prev) =>
prev.includes("push")
? prev.filter((c) => c !== "push")
: [...prev, "push"],
)
}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeChannels.includes("push")
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
<Bell className="h-3.5 w-3.5" />
Push
</button>
<button
type="button"
onClick={() =>
setComposeChannels((prev) =>
prev.includes("sms")
? prev.filter((c) => c !== "sms")
: [...prev, "sms"],
)
}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeChannels.includes("sms")
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
<Mail className="h-3.5 w-3.5" />
SMS
</button>
<div className="flex flex-wrap gap-1 rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
{CHANNELS.map(({ value, label, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => {
setChannel(value)
if (value !== "sms" && value !== "email" && audienceMode === "direct") {
setAudienceMode("role")
}
if (value === "sms") setTitle("")
}}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
channel === value
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
Audience
Delivery
</p>
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => setComposeAudience("all")}
onClick={() => {
setSendMode("now")
setScheduledAt("")
}}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeAudience === "all"
sendMode === "now"
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
All users
Send now
</button>
<button
type="button"
onClick={() => setComposeAudience("selected")}
onClick={() => setSendMode("schedule")}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
composeAudience === "selected"
sendMode === "schedule"
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
Selected users
<CalendarClock className="h-3.5 w-3.5" />
Schedule
</button>
</div>
</div>
</div>
{/* Title & message */}
<div className="space-y-3">
{isScheduling && (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Title
Scheduled at (UTC)
</label>
<Input
placeholder="Short headline for this notification"
value={composeTitle}
onChange={(e) => setComposeTitle(e.target.value)}
/>
<NotificationSchedulePicker value={scheduledAt} onChange={setScheduledAt} />
<p className="mt-1 text-[10px] text-grayScale-400">
Attachments and push images are not supported for scheduled sends.
</p>
</div>
)}
<div className="space-y-3">
{needsTitle && (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
{titleLabel}
</label>
<Input
placeholder={
channel === "email"
? "Email subject line"
: "Short headline for this notification"
}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Message
@ -223,73 +413,112 @@ export function CreateNotificationPage() {
<Textarea
rows={4}
placeholder={
composeChannels.includes("sms") && !composeChannels.includes("push")
? "Concise SMS body. Keep it clear and under 160 characters where possible."
: "Notification body shown inside the app."
channel === "sms"
? "SMS body text."
: "Notification body shown to recipients."
}
value={composeMessage}
onChange={(e) => setComposeMessage(e.target.value)}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
{channel === "email" && (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
HTML body (optional)
</label>
<Textarea
rows={3}
placeholder="<p>Rich HTML content</p>"
value={htmlBody}
onChange={(e) => setHtmlBody(e.target.value)}
/>
</div>
)}
{channel === "in_app" && (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Type
</label>
<Select value={inAppType} onChange={(e) => setInAppType(e.target.value)}>
{IN_APP_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Level
</label>
<Select
value={inAppLevel}
onChange={(e) =>
setInAppLevel(e.target.value as InAppNotificationLevel)
}
>
{IN_APP_LEVELS.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</Select>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Image upload */}
<Card className="shadow-none border border-grayScale-100">
<CardContent className="space-y-2 p-4">
<p className="mb-1 block text-xs font-medium text-grayScale-500">
Image (push only)
</p>
<FileUpload
accept="image/*"
onFileSelect={setComposeImage}
label="Upload notification image"
description="Shown with push notification where supported"
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
<p className="text-[10px] text-grayScale-400">
Image will be ignored for SMS-only sends. Connect your push provider to attach it to
real notifications.
</p>
</CardContent>
</Card>
{supportsAttachment && (
<Card className="border border-grayScale-100 shadow-none">
<CardContent className="space-y-2 p-4">
<p className="mb-1 block text-xs font-medium text-grayScale-500">
{channel === "push" ? "Image (push only)" : "Attachment (email only)"}
</p>
<FileUpload
accept={channel === "push" ? "image/*" : undefined}
onFileSelect={setAttachment}
label={channel === "push" ? "Upload notification image" : "Upload attachment"}
description={
channel === "push"
? "Shown with push notification where supported"
: "Optional file attached to the email"
}
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</CardContent>
</Card>
)}
{/* Footer actions */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
<p className="text-[11px] text-grayScale-400">
This is a UI-only preview. Hook into your notification API to deliver messages.
{isScheduling
? "Creates a scheduled job processed by the backend worker."
: "Delivers immediately with sent/failed counts returned."}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setComposeTitle("")
setComposeMessage("")
setComposeAudience("all")
setComposeChannels(["push"])
setSelectedRecipientIds([])
setComposeImage(null)
}}
>
<Button type="button" variant="outline" size="sm" onClick={resetForm} disabled={sending}>
Clear
</Button>
<Button
type="submit"
size="sm"
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
disabled={
sending ||
(!message.trim() && !(channel === "email" && htmlBody.trim()))
}
>
{sending ? (
<>
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
Sending
{isScheduling ? "Scheduling…" : "Sending…"}
</>
) : (
<>
<MailOpen className="mr-2 h-3.5 w-3.5" />
Send notification
{isScheduling ? "Schedule notification" : "Send notification"}
</>
)}
</Button>
@ -297,122 +526,233 @@ export function CreateNotificationPage() {
</div>
</div>
{/* Right: audience & preview */}
<div className="space-y-4">
<Card className="shadow-none border border-grayScale-100">
<Card className="border border-grayScale-100 shadow-none">
<CardContent className="space-y-3 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-600">Audience & channels</p>
<span className="inline-flex items-center gap-1 rounded-full bg-grayScale-100 px-2 py-0.5 text-[10px] font-medium text-grayScale-500">
{composeChannels.join(" + ").toUpperCase() || "—"}
</span>
</div>
<div className="space-y-1 text-[11px] text-grayScale-500">
<p>
<span className="font-semibold text-grayScale-600">Audience:</span>{" "}
{composeAudience === "all"
? "All users"
: selectedRecipients.length === 0
? "No users selected yet"
: `${selectedRecipients.length} selected user${
selectedRecipients.length === 1 ? "" : "s"
}`}
</p>
<p>
<span className="font-semibold text-grayScale-600">Channels:</span>{" "}
{composeChannels.length === 0
? "None selected"
: composeChannels.map((c) => c.toUpperCase()).join(" + ")}
</p>
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="space-y-2 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-600">Selected users</p>
{composeAudience === "selected" && (
<span className="text-[10px] text-grayScale-400">
{selectedRecipients.length} selected
</span>
<p className="text-xs font-semibold text-grayScale-600">Audience</p>
<div className="inline-flex flex-wrap gap-1 rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => setAudienceMode("role")}
className={cn(
"rounded-full px-3 py-1.5 transition-colors",
audienceMode === "role"
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
By role
</button>
<button
type="button"
onClick={() => {
setAudienceMode("selected")
setUserSearchQuery("")
}}
className={cn(
"rounded-full px-3 py-1.5 transition-colors",
audienceMode === "selected"
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
Selected users
</button>
{supportsDirect && (
<button
type="button"
onClick={() => setAudienceMode("direct")}
className={cn(
"rounded-full px-3 py-1.5 transition-colors",
audienceMode === "direct"
? "bg-brand-500 text-white shadow-sm"
: "text-grayScale-500 hover:text-grayScale-700",
)}
>
Direct {channel === "sms" ? "phones" : "emails"}
</button>
)}
</div>
{composeAudience === "all" ? (
<p className="text-[11px] text-grayScale-400">
All eligible users will receive this notification. Switch to{" "}
<span className="font-semibold text-grayScale-600">Selected users</span> to target
specific people.
</p>
) : (
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-lg border border-grayScale-100 bg-grayScale-50/60 p-2">
{recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
Loading users
</div>
)}
{!recipientsLoading && teamRecipients.length === 0 && (
<div className="py-4 text-center text-xs text-grayScale-400">
No users available to select.
</div>
)}
{!recipientsLoading &&
teamRecipients.map((member) => {
const checked = selectedRecipientIds.includes(member.id)
return (
<label
key={member.id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs",
checked ? "bg-brand-50 text-brand-700" : "hover:bg-grayScale-100",
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5 rounded border-grayScale-300"
checked={checked}
onChange={(e) => {
setSelectedRecipientIds((prev) =>
e.target.checked
? [...prev, member.id]
: prev.filter((id) => id !== member.id),
)
}}
/>
<span className="truncate">
{member.first_name} {member.last_name}
<span className="ml-1 text-[10px] text-grayScale-400">
· {member.email}
</span>
</span>
</label>
)
})}
{audienceMode === "role" && (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Platform role
</label>
<Select
value={platformRole}
onChange={(e) => setPlatformRole(e.target.value as PlatformRole)}
>
{PLATFORM_ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</Select>
<p className="mt-1 text-[10px] text-grayScale-400">
Sends to all users with this platform role.
</p>
</div>
)}
{audienceMode === "direct" && (
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
{channel === "sms" ? "Phone numbers" : "Email addresses"}
</label>
<Textarea
rows={4}
placeholder={
channel === "sms"
? "+251911000000\n+251922000000"
: "user@example.com\nadmin@example.com"
}
value={directRecipients}
onChange={(e) => setDirectRecipients(e.target.value)}
/>
<p className="mt-1 text-[10px] text-grayScale-400">
One per line or comma-separated.
</p>
</div>
)}
</CardContent>
</Card>
{/* Mobile preview card */}
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/40">
{audienceMode === "selected" && (
<Card className="border border-grayScale-100 shadow-none">
<CardContent className="space-y-2 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-grayScale-600">Selected users</p>
<span className="text-[10px] text-grayScale-400">
{selectedUsers.length} selected
</span>
</div>
<div className="overflow-hidden rounded-lg border border-grayScale-100 bg-grayScale-50/60">
<div className="sticky top-0 z-10 space-y-2 border-b border-grayScale-100 bg-grayScale-50/95 p-2 backdrop-blur-sm">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-grayScale-400" />
<Input
type="search"
placeholder="Search by name, email, or phone…"
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
className="h-8 border-grayScale-200 bg-white pl-8 text-xs"
disabled={recipientsLoading}
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={recipientsLoading || filteredUsers.length === 0}
onClick={() => {
const ids = filteredUsers.map((u) => u.id)
setSelectedUserIds((prev) => [...new Set([...prev, ...ids])])
}}
className="text-[11px] font-medium text-brand-600 hover:text-brand-700 disabled:cursor-not-allowed disabled:opacity-40"
>
Select all
</button>
<span className="text-grayScale-300">·</span>
<button
type="button"
disabled={recipientsLoading || filteredSelectedCount === 0}
onClick={() => {
const filteredIds = new Set(filteredUsers.map((u) => u.id))
setSelectedUserIds((prev) => prev.filter((id) => !filteredIds.has(id)))
}}
className="text-[11px] font-medium text-grayScale-500 hover:text-grayScale-700 disabled:cursor-not-allowed disabled:opacity-40"
>
Unselect all
</button>
{userSearchQuery.trim() && filteredUsers.length > 0 && (
<span className="ml-auto text-[10px] text-grayScale-400">
{filteredUsers.length} shown
</span>
)}
</div>
</div>
<div className="max-h-64 space-y-1.5 overflow-y-auto p-2">
{recipientsLoading && (
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
Loading users
</div>
)}
{!recipientsLoading && users.length === 0 && (
<div className="py-4 text-center text-xs text-grayScale-400">
No users available to select.
</div>
)}
{!recipientsLoading && users.length > 0 && filteredUsers.length === 0 && (
<div className="py-4 text-center text-xs text-grayScale-400">
No users match &ldquo;{userSearchQuery.trim()}&rdquo;.
</div>
)}
{!recipientsLoading &&
filteredUsers.map((user) => {
const checked = selectedUserIds.includes(user.id)
return (
<label
key={user.id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs",
checked ? "bg-brand-50 text-brand-700" : "hover:bg-grayScale-100",
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5 rounded border-grayScale-300"
checked={checked}
onChange={(e) => {
setSelectedUserIds((prev) =>
e.target.checked
? [...prev, user.id]
: prev.filter((id) => id !== user.id),
)
}}
/>
<span className="truncate">
{user.first_name} {user.last_name}
<span className="ml-1 text-[10px] text-grayScale-400">
· {user.email ?? user.phone_number ?? `ID ${user.id}`}
</span>
</span>
</label>
)
})}
</div>
</div>
</CardContent>
</Card>
)}
<Card className="border border-dashed border-grayScale-200 bg-grayScale-50/40 shadow-none">
<CardContent className="space-y-2 p-4">
<p className="text-xs font-semibold text-grayScale-600">Preview</p>
<div className="space-y-1 rounded-xl border border-grayScale-200 bg-white p-3 text-xs">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-brand-500/90 text-white">
<Bell className="h-3.5 w-3.5" />
{(() => {
const Icon = previewIcon
return <Icon className="h-3.5 w-3.5" />
})()}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-grayScale-800">
{composeTitle || "Notification title"}
{title || (channel === "sms" ? "SMS message" : "Notification title")}
</p>
<p className="truncate text-[11px] text-grayScale-500">
{composeMessage || "Message preview will appear here."}
{message || "Message preview will appear here."}
</p>
</div>
</div>
</div>
<p className="text-[10px] text-grayScale-400">
Channel: {channel.toUpperCase().replace("_", "-")}
{isScheduling && scheduledAt
? ` · Scheduled ${formatScheduledAtLabel(scheduledAt)}`
: ""}
</p>
</CardContent>
</Card>
</div>
@ -420,4 +760,3 @@ export function CreateNotificationPage() {
</div>
)
}

View File

@ -1,46 +1,29 @@
import { useEffect, useState, useCallback, useMemo } from "react"
import { useEffect, useState, useCallback } from "react"
import {
Bell,
BellOff,
AlertTriangle,
ChevronLeft,
ChevronRight,
Megaphone,
MailOpen,
Mail,
CheckCheck,
MailX,
Search,
ChevronDown,
Calendar,
Clock3,
} from "lucide-react"
import { Card, CardContent } from "../../components/ui/card"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../components/ui/dropdown-menu"
import { FileUpload } from "../../components/ui/file-upload"
import { cn } from "../../lib/utils"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { useNavigate } from "react-router-dom"
@ -52,9 +35,6 @@ import {
markAsUnread,
markAllRead,
markAllUnread,
sendBulkSms,
sendBulkEmail,
sendBulkPush,
} from "../../api/notifications.api"
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
import {
@ -64,20 +44,10 @@ import {
getNotificationLevelBadge,
NOTIFICATION_TYPE_CONFIG,
} from "../../lib/notificationDisplay"
import { getRoles } from "../../api/rbac.api"
import { getTeamMembers } from "../../api/team.api"
import { getUsers } from "../../api/users.api"
import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types"
import type { Role } from "../../types/rbac.types"
import type { TeamMember } from "../../types/team.types"
import type { UserApiDTO } from "../../types/user.types"
import { toast } from "sonner"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
function digitsOnly(value: string, maxLength: number) {
return value.replace(/\D/g, "").slice(0, maxLength)
}
function NotificationItem({
notification,
onToggleRead,
@ -220,152 +190,6 @@ export function NotificationsPage() {
const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
const [levelFilter, setLevelFilter] = useState<"all" | string>("all")
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkChannel, setBulkChannel] = useState<"sms" | "email" | "push">("sms")
const [bulkTitle, setBulkTitle] = useState("")
const [bulkMessage, setBulkMessage] = useState("")
const [bulkRole, setBulkRole] = useState("")
const [bulkUserIds, setBulkUserIds] = useState<number[]>([])
const [bulkScheduledAt, setBulkScheduledAt] = useState("")
const [bulkFile, setBulkFile] = useState<File | null>(null)
const [bulkSending, setBulkSending] = useState(false)
const [bulkRoles, setBulkRoles] = useState<Role[]>([])
const [bulkUsers, setBulkUsers] = useState<UserApiDTO[]>([])
const [bulkRolesLoading, setBulkRolesLoading] = useState(false)
const [bulkUsersLoading, setBulkUsersLoading] = useState(false)
const [scheduleMenuOpen, setScheduleMenuOpen] = useState(false)
const [scheduleYear, setScheduleYear] = useState("")
const [scheduleMonth, setScheduleMonth] = useState("")
const [scheduleDay, setScheduleDay] = useState("")
const [scheduleHour, setScheduleHour] = useState("")
const [scheduleMinute, setScheduleMinute] = useState("")
const filteredBulkUsers = useMemo(() => {
if (!bulkRole.trim()) return bulkUsers
const selectedRole = bulkRole.trim().toLowerCase()
return bulkUsers.filter((user) => user.role?.toLowerCase() === selectedRole)
}, [bulkUsers, bulkRole])
const scheduledAtLabel = useMemo(() => {
if (!bulkScheduledAt) return "Set date & time"
const parsed = new Date(bulkScheduledAt)
if (Number.isNaN(parsed.getTime())) return bulkScheduledAt
return parsed.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}, [bulkScheduledAt])
const loadBulkOptions = useCallback(async () => {
if (!bulkOpen) return
const needsRoles = bulkRoles.length === 0
const needsUsers = bulkUsers.length === 0
if (!needsRoles && !needsUsers) return
try {
if (needsRoles) setBulkRolesLoading(true)
if (needsUsers) setBulkUsersLoading(true)
const tasks: Promise<unknown>[] = []
if (needsRoles) {
tasks.push(
getRoles({ page: 1, page_size: 20 })
.then(async (res) => {
const firstBatch = res.data?.data?.roles ?? []
const total = res.data?.data?.total ?? firstBatch.length
const pageSize = 20
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (totalPages <= 1) {
setBulkRoles(firstBatch)
return
}
const remainingRequests: Array<ReturnType<typeof getRoles>> = []
for (let page = 2; page <= totalPages; page += 1) {
remainingRequests.push(getRoles({ page, page_size: pageSize }))
}
try {
const responses = await Promise.all(remainingRequests)
const rest = responses.flatMap((r) => r.data?.data?.roles ?? [])
setBulkRoles([...firstBatch, ...rest])
} catch {
setBulkRoles(firstBatch)
}
})
.catch(() => {
setBulkRoles([])
}),
)
}
if (needsUsers) {
tasks.push(
getUsers({ page: 1, page_size: 20 })
.then(async (res) => {
const firstBatch = res.data?.data?.users ?? []
const total = res.data?.data?.total ?? firstBatch.length
const pageSize = 20
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (totalPages <= 1) {
setBulkUsers(firstBatch)
return
}
const remainingRequests: Array<ReturnType<typeof getUsers>> = []
for (let page = 2; page <= totalPages; page += 1) {
remainingRequests.push(getUsers({ page, page_size: pageSize }))
}
try {
const responses = await Promise.all(remainingRequests)
const rest = responses.flatMap((r) => r.data?.data?.users ?? [])
setBulkUsers([...firstBatch, ...rest])
} catch {
setBulkUsers(firstBatch)
}
})
.catch(() => {
setBulkUsers([])
}),
)
}
await Promise.all(tasks)
} finally {
setBulkRolesLoading(false)
setBulkUsersLoading(false)
}
}, [bulkOpen, bulkRoles.length, bulkUsers.length])
useEffect(() => {
loadBulkOptions()
}, [loadBulkOptions])
useEffect(() => {
if (!scheduleMenuOpen) return
if (!bulkScheduledAt) {
setScheduleYear("")
setScheduleMonth("")
setScheduleDay("")
setScheduleHour("")
setScheduleMinute("")
return
}
const [datePart = "", timePart = ""] = bulkScheduledAt.split("T")
const [y = "", m = "", d = ""] = datePart.split("-")
const [hh = "", mm = ""] = timePart.split(":")
setScheduleYear(y)
setScheduleMonth(m)
setScheduleDay(d)
setScheduleHour(hh)
setScheduleMinute(mm.slice(0, 2))
}, [scheduleMenuOpen, bulkScheduledAt])
const fetchData = useCallback(async (currentOffset: number) => {
setLoading(true)
setError(false)
@ -992,487 +816,6 @@ export function NotificationsPage() {
: undefined
}
/>
{/* Bulk send dialog */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone className="h-5 w-5 text-brand-500" />
<span>Send notification</span>
</DialogTitle>
<DialogDescription>
Send a bulk SMS, email, or push notification to users.
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={async (e) => {
e.preventDefault()
if (!bulkMessage.trim()) {
toast.error("Message is required")
return
}
const userIds = bulkUserIds
try {
setBulkSending(true)
if (bulkChannel === "sms") {
if (userIds.length === 0) {
toast.error("User IDs are required for bulk SMS")
setBulkSending(false)
return
}
await sendBulkSms({
message: bulkMessage.trim(),
user_ids: userIds,
...(bulkScheduledAt ? { scheduled_at: bulkScheduledAt } : {}),
})
} else if (bulkChannel === "email") {
const form = new FormData()
if (!bulkTitle.trim()) {
toast.error("Subject is required for bulk email")
setBulkSending(false)
return
}
form.append("subject", bulkTitle.trim())
form.append("message", bulkMessage.trim())
if (bulkRole.trim()) form.append("role", bulkRole.trim())
if (userIds.length > 0) {
form.append("user_ids", JSON.stringify(userIds))
}
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
if (bulkFile) form.append("file", bulkFile)
await sendBulkEmail(form)
} else {
const form = new FormData()
if (!bulkTitle.trim()) {
toast.error("Title is required for bulk push")
setBulkSending(false)
return
}
form.append("title", bulkTitle.trim())
form.append("message", bulkMessage.trim())
if (bulkRole.trim()) form.append("role", bulkRole.trim())
if (userIds.length > 0) {
form.append("user_ids", JSON.stringify(userIds))
}
if (bulkScheduledAt) form.append("scheduled_at", bulkScheduledAt)
if (bulkFile) form.append("file", bulkFile)
await sendBulkPush(form)
}
toast.success("Notification scheduled", {
description: bulkScheduledAt
? "Notification has been scheduled successfully."
: "Notification has been sent successfully.",
})
setBulkTitle("")
setBulkMessage("")
setBulkRole("")
setBulkUserIds([])
setBulkScheduledAt("")
setBulkFile(null)
setBulkChannel("sms")
setBulkOpen(false)
} catch (err: any) {
const msg =
err?.response?.data?.message ||
"Failed to send notification. Please try again."
toast.error("Failed to send notification", { description: msg })
} finally {
setBulkSending(false)
}
}}
>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.1fr)_minmax(0,1.3fr)]">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Channel
</label>
<Select
value={bulkChannel}
onChange={(e) => setBulkChannel(e.target.value as typeof bulkChannel)}
>
<option value="sms">Bulk SMS</option>
<option value="email">Bulk email</option>
<option value="push">Bulk push</option>
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
{bulkChannel === "email" ? "Subject" : "Title (push only)"}
</label>
<Input
placeholder={
bulkChannel === "email"
? `e.g. "System Update"`
: `e.g. "System Update"`
}
value={bulkTitle}
onChange={(e) => setBulkTitle(e.target.value)}
/>
</div>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Message
</label>
<Textarea
rows={3}
placeholder={
bulkChannel === "sms"
? "Text body to send by SMS."
: "Notification body for email or push."
}
value={bulkMessage}
onChange={(e) => setBulkMessage(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Role (optional)
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={bulkRolesLoading}
className={cn(
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
bulkRolesLoading && "cursor-not-allowed opacity-50",
)}
>
<span className="truncate text-left">
{bulkRolesLoading ? "Loading roles..." : bulkRole || "All roles"}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[220px]">
<DropdownMenuLabel>Roles</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={bulkRole}
onValueChange={(value) => {
setBulkRole(value)
setBulkUserIds([])
}}
>
<DropdownMenuRadioItem value="">All roles</DropdownMenuRadioItem>
{bulkRoles.map((role) => (
<DropdownMenuRadioItem key={role.id} value={role.name}>
{role.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Users (optional)
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={bulkUsersLoading}
className={cn(
"flex h-10 w-full items-center justify-between rounded-lg border bg-white px-3 text-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
bulkUsersLoading && "cursor-not-allowed opacity-50",
)}
>
<span className="truncate text-left">
{bulkUsersLoading
? "Loading users..."
: bulkUserIds.length === 0
? "Select users"
: `${bulkUserIds.length} user(s) selected`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-grayScale-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px]">
<DropdownMenuLabel>Users</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setBulkUserIds(filteredBulkUsers.map((u) => u.id))
}}
disabled={filteredBulkUsers.length === 0}
>
Select all
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setBulkUserIds([])
}}
disabled={bulkUserIds.length === 0}
>
Deselect all
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto">
{filteredBulkUsers.length === 0 ? (
<p className="px-2 py-2 text-xs text-grayScale-400">No users available</p>
) : (
filteredBulkUsers.map((user) => {
const isChecked = bulkUserIds.includes(user.id)
return (
<DropdownMenuCheckboxItem
key={user.id}
checked={isChecked}
onSelect={(e) => e.preventDefault()}
onCheckedChange={(checked) => {
setBulkUserIds((prev) => {
if (checked) return prev.includes(user.id) ? prev : [...prev, user.id]
return prev.filter((id) => id !== user.id)
})
}}
>
{user.first_name} {user.last_name} ({user.id})
</DropdownMenuCheckboxItem>
)
})
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<p className="text-[11px] text-grayScale-400">Choose one or more users from the dropdown list.</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1.2fr)]">
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-500">
File attachment (optional)
</label>
<FileUpload
accept="image/*"
onFileSelect={setBulkFile}
label="Upload image or file"
description="Optional image or asset to attach"
className="min-h-[110px] rounded-lg border-2 border-dashed border-grayScale-300 transition-colors hover:border-brand-400 hover:bg-brand-50/30"
/>
</div>
<div className="space-y-2">
<label className="mb-1 block text-xs font-medium text-grayScale-500">
Scheduled at (optional)
</label>
<DropdownMenu open={scheduleMenuOpen} onOpenChange={setScheduleMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex h-11 w-full items-center justify-between rounded-xl border border-grayScale-200 bg-grayScale-50/70 px-3 text-sm text-grayScale-700 shadow-sm transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-100",
)}
>
<span className="truncate text-left">{scheduledAtLabel}</span>
<span className="ml-2 inline-flex items-center gap-1 rounded-md border border-grayScale-200 bg-white px-2 py-1 text-[11px] text-grayScale-500">
<Calendar className="h-3.5 w-3.5" />
<Clock3 className="h-3.5 w-3.5" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[320px] p-3">
<p className="mb-2 text-xs font-semibold text-grayScale-500">Schedule notification</p>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Date</label>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="YYYY"
value={scheduleYear}
onChange={(e) => setScheduleYear(digitsOnly(e.target.value, 4))}
inputMode="numeric"
maxLength={4}
className="h-9 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">-</span>
<Input
type="text"
placeholder="MM"
value={scheduleMonth}
onChange={(e) => setScheduleMonth(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">-</span>
<Input
type="text"
placeholder="DD"
value={scheduleDay}
onChange={(e) => setScheduleDay(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
</div>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium text-grayScale-500">Time</label>
<div className="flex items-center gap-1.5">
<Input
type="text"
placeholder="HH"
value={scheduleHour}
onChange={(e) => setScheduleHour(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
<span className="text-grayScale-400">:</span>
<Input
type="text"
placeholder="MM"
value={scheduleMinute}
onChange={(e) => setScheduleMinute(digitsOnly(e.target.value, 2))}
inputMode="numeric"
maxLength={2}
className="h-9 w-16 rounded-lg border-grayScale-200 bg-white text-center text-sm"
/>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => {
const now = new Date()
setScheduleYear(String(now.getFullYear()))
setScheduleMonth(String(now.getMonth() + 1).padStart(2, "0"))
setScheduleDay(String(now.getDate()).padStart(2, "0"))
}}
>
Today
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => {
setScheduleYear("")
setScheduleMonth("")
setScheduleDay("")
setScheduleHour("")
setScheduleMinute("")
setBulkScheduledAt("")
}}
>
Clear
</Button>
</div>
<Button
type="button"
size="sm"
className="h-8"
onClick={() => {
const year = Number(scheduleYear)
const month = Number(scheduleMonth)
const day = Number(scheduleDay)
const hour = Number(scheduleHour)
const minute = Number(scheduleMinute)
const formatOk =
scheduleYear.length === 4 &&
scheduleMonth.length === 2 &&
scheduleDay.length === 2 &&
scheduleHour.length === 2 &&
scheduleMinute.length === 2
const dateValue = new Date(year, month - 1, day)
const dateOk =
formatOk &&
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= 31 &&
dateValue.getFullYear() === year &&
dateValue.getMonth() === month - 1 &&
dateValue.getDate() === day
const timeOk = hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
if (!dateOk || !timeOk) {
toast.error("Use valid date/time format", {
description: "Date: YYYY-MM-DD, Time: HH:MM (24h).",
})
return
}
setBulkScheduledAt(
`${scheduleYear}-${scheduleMonth}-${scheduleDay}T${scheduleHour}:${scheduleMinute}`,
)
setScheduleMenuOpen(false)
}}
>
Apply
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
<p className="text-[11px] text-grayScale-400">
Leave empty to send immediately. When set, the notification is stored in{" "}
<code>scheduled_notifications</code> and sent at the specified time.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setBulkTitle("")
setBulkMessage("")
setBulkRole("")
setBulkUserIds([])
setBulkScheduledAt("")
setBulkFile(null)
setBulkChannel("sms")
setBulkOpen(false)
}}
disabled={bulkSending}
>
Cancel
</Button>
<Button type="submit" size="sm" disabled={bulkSending || !bulkMessage.trim()}>
{bulkSending ? (
<>
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
Sending
</>
) : (
<>
<MailOpen className="mr-2 h-3.5 w-3.5" />
Send
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,318 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import {
CalendarClock,
ChevronLeft,
ChevronRight,
Megaphone,
Plus,
RefreshCw,
XCircle,
} from "lucide-react"
import { toast } from "sonner"
import {
cancelScheduledNotification,
getScheduledNotifications,
} from "../../api/notifications.api"
import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button"
import { Card, CardContent } from "../../components/ui/card"
import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table"
import {
channelLabel,
formatScheduledAtLabel,
scheduledStatusBadgeVariant,
} from "../../lib/notificationBulk"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE } from "../../lib/tablePagination"
import type {
NotificationChannel,
ScheduledNotification,
ScheduledNotificationStatus,
} from "../../types/notification.types"
const STATUS_OPTIONS: Array<{ value: "" | ScheduledNotificationStatus; label: string }> = [
{ value: "", label: "All statuses" },
{ value: "pending", label: "Pending" },
{ value: "processing", label: "Processing" },
{ value: "sent", label: "Sent" },
{ value: "failed", label: "Failed" },
{ value: "cancelled", label: "Cancelled" },
]
const CHANNEL_OPTIONS: Array<{ value: "" | NotificationChannel; label: string }> = [
{ value: "", label: "All channels" },
{ value: "sms", label: "SMS" },
{ value: "email", label: "Email" },
{ value: "push", label: "Push" },
{ value: "in_app", label: "In-app" },
]
export function ScheduledNotificationsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [rows, setRows] = useState<ScheduledNotification[]>([])
const [totalCount, setTotalCount] = useState(0)
const [page, setPage] = useState(1)
const [limit] = useState(DEFAULT_TABLE_PAGE_SIZE)
const [statusFilter, setStatusFilter] = useState<"" | ScheduledNotificationStatus>("")
const [channelFilter, setChannelFilter] = useState<"" | NotificationChannel>("")
const [cancellingId, setCancellingId] = useState<number | null>(null)
const totalPages = useMemo(
() => Math.max(1, Math.ceil(totalCount / limit)),
[totalCount, limit],
)
const load = useCallback(async () => {
setLoading(true)
setError(false)
try {
const res = await getScheduledNotifications({
page,
limit,
...(statusFilter ? { status: statusFilter } : {}),
...(channelFilter ? { channel: channelFilter } : {}),
})
setRows(res.data.scheduled_notifications)
setTotalCount(res.data.total_count)
} catch {
setError(true)
setRows([])
setTotalCount(0)
toast.error("Failed to load scheduled notifications")
} finally {
setLoading(false)
}
}, [page, limit, statusFilter, channelFilter])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
setPage(1)
}, [statusFilter, channelFilter])
const handleCancel = async (job: ScheduledNotification) => {
if (job.status !== "pending" && job.status !== "processing") return
try {
setCancellingId(job.id)
await cancelScheduledNotification(job.id)
toast.success("Scheduled notification cancelled", {
description: `Job #${job.id} was cancelled.`,
})
await load()
} catch {
toast.error("Failed to cancel scheduled notification")
} finally {
setCancellingId(null)
}
}
const targetingSummary = (job: ScheduledNotification) => {
if (job.target_role) return `Role: ${job.target_role}`
if (job.target_user_ids?.length) {
return `${job.target_user_ids.length} user(s)`
}
if (job.target_raw?.phones?.length) {
return `${job.target_raw.phones.length} phone(s)`
}
if (job.target_raw?.emails?.length) {
return `${job.target_raw.emails.length} email(s)`
}
return "—"
}
return (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-grayScale-500">Notifications</p>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight text-grayScale-800">
<CalendarClock className="h-6 w-6 text-brand-500" />
Scheduled notifications
</h1>
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
View pending and completed bulk notification jobs. Cancel jobs before they are sent.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button className="shrink-0 bg-brand-500 text-white hover:bg-brand-600" asChild>
<Link to="/notifications/create">
<Plus className="mr-2 h-4 w-4" />
Send notification
</Link>
</Button>
<Button variant="outline" className="shrink-0" disabled={loading} onClick={() => void load()}>
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
Refresh
</Button>
</div>
</div>
<Card className="border border-grayScale-100 shadow-none">
<CardContent className="space-y-4 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="sm:max-w-[180px]"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</Select>
<Select
value={channelFilter}
onChange={(e) => setChannelFilter(e.target.value as typeof channelFilter)}
className="sm:max-w-[180px]"
>
{CHANNEL_OPTIONS.map((opt) => (
<option key={opt.value || "all"} value={opt.value}>
{opt.label}
</option>
))}
</Select>
<Badge variant="secondary" className="w-fit">
{totalCount} job{totalCount === 1 ? "" : "s"}
</Badge>
</div>
{loading && (
<div className="flex items-center justify-center py-16 text-sm text-grayScale-500">
<SpinnerIcon className="mr-2 h-5 w-5" alt="" />
Loading scheduled jobs
</div>
)}
{!loading && error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center text-sm text-destructive">
Could not load scheduled notifications.
<Button variant="outline" size="sm" className="ml-3" onClick={() => void load()}>
Retry
</Button>
</div>
)}
{!loading && !error && rows.length === 0 && (
<div className="rounded-lg border border-dashed border-grayScale-200 bg-grayScale-50/50 py-16 text-center">
<Megaphone className="mx-auto mb-3 h-8 w-8 text-grayScale-300" />
<p className="text-sm font-medium text-grayScale-600">No scheduled jobs found</p>
<p className="mt-1 text-xs text-grayScale-400">
Schedule a notification from the composer to see it here.
</p>
<Button className="mt-4" size="sm" asChild>
<Link to="/notifications/create">Send notification</Link>
</Button>
</div>
)}
{!loading && !error && rows.length > 0 && (
<>
<div className="overflow-x-auto rounded-lg border border-grayScale-100">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Title / message</TableHead>
<TableHead>Audience</TableHead>
<TableHead>Scheduled</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((job) => (
<TableRow key={job.id}>
<TableCell className="font-mono text-xs">#{job.id}</TableCell>
<TableCell>{channelLabel(job.channel)}</TableCell>
<TableCell className="max-w-[240px]">
<p className="truncate text-sm font-medium text-grayScale-700">
{job.title || "—"}
</p>
<p className="truncate text-xs text-grayScale-400">{job.message}</p>
</TableCell>
<TableCell className="text-xs text-grayScale-500">
{targetingSummary(job)}
</TableCell>
<TableCell className="text-xs text-grayScale-500">
{formatScheduledAtLabel(job.scheduled_at)}
</TableCell>
<TableCell>
<Badge variant={scheduledStatusBadgeVariant(job.status)}>
{job.status}
</Badge>
{job.last_error && job.status === "failed" && (
<p className="mt-1 max-w-[180px] truncate text-[10px] text-destructive">
{job.last_error}
</p>
)}
</TableCell>
<TableCell className="text-right">
{(job.status === "pending" || job.status === "processing") && (
<Button
variant="outline"
size="sm"
disabled={cancellingId === job.id}
onClick={() => void handleCancel(job)}
>
{cancellingId === job.id ? (
<SpinnerIcon className="mr-1 h-3.5 w-3.5" alt="" />
) : (
<XCircle className="mr-1 h-3.5 w-3.5" />
)}
Cancel
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between gap-3 pt-2">
<p className="text-xs text-grayScale-500">
Page {page} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -633,6 +633,103 @@ export interface UpdateParentLinkedPracticeResponse {
metadata: unknown | null
}
/** Question row in GET/PUT /practices/:id/full and /exam-prep/practices/:id/full. */
export interface PracticeFullQuestionItem {
id?: number | null
display_order: number
question_text?: string
question_type: string
question_type_definition_id?: number | null
dynamic_payload?: DynamicQuestionPayload | null
difficulty_level?: string
points?: number
status?: PracticePublishStatus | string
options?: QuestionOption[]
short_answers?: QuestionShortAnswer[] | string[]
voice_prompt?: string
sample_answer_voice_prompt?: string
audio_correct_answer_text?: string
image_url?: string
tips?: string
explanation?: string
created_at?: string
}
export interface PracticeFullQuestionSet {
id: number
title: string
description?: string | null
set_type?: string
owner_type?: string
owner_id?: number
persona?: string | null
shuffle_questions?: boolean
status?: PracticePublishStatus | string
time_limit_minutes?: number | null
passing_score?: number | null
intro_video_url?: string | null
question_count?: number
created_at?: string
}
export interface PracticeFullPractice {
id: number
title: string
story_description?: string
story_image?: string
persona_id?: number | null
question_set_id: number
publish_status?: PracticePublishStatus | string | null
quick_tips?: string
lesson_id?: number
parent_kind?: string
parent_id?: number
created_at?: string
}
export interface PracticeFullData {
practice: PracticeFullPractice
question_set: PracticeFullQuestionSet
questions: PracticeFullQuestionItem[]
}
export interface GetPracticeFullResponse {
message: string
data: PracticeFullData
success: boolean
status_code: number
metadata: unknown | null
}
export interface UpdatePracticeFullRequest {
practice: {
title: string
story_description: string
story_image: string
persona_id: number
quick_tips: string
publish_status: PracticePublishStatus
}
question_set: {
title: string
description?: string | null
time_limit_minutes?: number | null
passing_score?: number | null
shuffle_questions: boolean
status: PracticePublishStatus
intro_video_url?: string | null
}
questions: PracticeFullQuestionItem[]
}
export interface UpdatePracticeFullResponse {
message: string
data: PracticeFullData
success: boolean
status_code: number
metadata: unknown | null
}
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
export interface UpdateTopLevelModuleLessonRequest {
title: string

View File

@ -55,3 +55,92 @@ export interface GetNotificationsResponse {
export interface UnreadCountResponse {
unread: number
}
export type NotificationChannel = "sms" | "email" | "push" | "in_app"
export type ScheduledNotificationStatus =
| "pending"
| "processing"
| "sent"
| "failed"
| "cancelled"
export type InAppNotificationLevel = "info" | "warning" | "error" | "success"
export type PlatformRole =
| "STUDENT"
| "OPEN_LEARNER"
| "INSTRUCTOR"
| "ADMIN"
| "SUPER_ADMIN"
| "SUPPORT"
export interface BulkSendResult {
total_recipients?: number
sent: number
failed: number
target_users?: number
image?: string
}
export interface ScheduledNotificationTargetRaw {
phones?: string[]
emails?: string[]
type?: string
level?: string
}
export interface ScheduledNotification {
id: number
channel: NotificationChannel
title?: string
message: string
html?: string
scheduled_at: string
status: ScheduledNotificationStatus
target_user_ids?: number[]
target_role?: string
target_raw?: ScheduledNotificationTargetRaw
attempt_count?: number
last_error?: string
processing_started_at?: string | null
sent_at?: string | null
cancelled_at?: string | null
created_by?: number
created_at: string
updated_at: string
}
export interface ListScheduledNotificationsResponse {
scheduled_notifications: ScheduledNotification[]
total_count: number
limit: number
page: number
}
export interface BulkSmsRequest {
message: string
user_ids?: number[]
role?: string
phone_numbers?: string[]
scheduled_at?: string
}
export interface BulkInAppRequest {
title: string
message: string
user_ids?: number[]
role?: string
scheduled_at?: string
type?: string
level?: InAppNotificationLevel
}
export interface GetScheduledNotificationsParams {
status?: ScheduledNotificationStatus
channel?: NotificationChannel
after?: string
before?: string
limit?: number
page?: number
}