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:
parent
babbed323c
commit
035d73889e
|
|
@ -108,6 +108,9 @@ import type {
|
||||||
CreateParentLinkedPracticeRequest,
|
CreateParentLinkedPracticeRequest,
|
||||||
CreateParentLinkedPracticeResponse,
|
CreateParentLinkedPracticeResponse,
|
||||||
UpdateParentLinkedPracticeRequest,
|
UpdateParentLinkedPracticeRequest,
|
||||||
|
GetPracticeFullResponse,
|
||||||
|
UpdatePracticeFullRequest,
|
||||||
|
UpdatePracticeFullResponse,
|
||||||
UpdateParentLinkedPracticeResponse,
|
UpdateParentLinkedPracticeResponse,
|
||||||
PublishParentLinkedPracticeRequest,
|
PublishParentLinkedPracticeRequest,
|
||||||
PublishStatusOnlyRequest,
|
PublishStatusOnlyRequest,
|
||||||
|
|
@ -846,6 +849,31 @@ export const updateParentLinkedPractice = (
|
||||||
data: UpdateParentLinkedPracticeRequest,
|
data: UpdateParentLinkedPracticeRequest,
|
||||||
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
|
) => 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). */
|
/** PUT /practices/:id — set publish_status only (Learn English practice). */
|
||||||
export const setLearnEnglishPracticePublishStatus = (
|
export const setLearnEnglishPracticePublishStatus = (
|
||||||
practiceId: number,
|
practiceId: number,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import http from "./http"
|
import http from "./http"
|
||||||
import type {
|
import type {
|
||||||
|
BulkInAppRequest,
|
||||||
|
BulkSendResult,
|
||||||
|
BulkSmsRequest,
|
||||||
GetNotificationsResponse,
|
GetNotificationsResponse,
|
||||||
|
GetScheduledNotificationsParams,
|
||||||
|
ListScheduledNotificationsResponse,
|
||||||
Notification,
|
Notification,
|
||||||
|
ScheduledNotification,
|
||||||
UnreadCountResponse,
|
UnreadCountResponse,
|
||||||
} from "../types/notification.types"
|
} from "../types/notification.types"
|
||||||
|
import { isScheduledNotification, parseBulkResponseData } from "../lib/notificationBulk"
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
|
|
@ -111,15 +118,129 @@ export const markAllRead = () =>
|
||||||
export const markAllUnread = () =>
|
export const markAllUnread = () =>
|
||||||
http.post("/notifications/mark-all-unread")
|
http.post("/notifications/mark-all-unread")
|
||||||
|
|
||||||
export const sendBulkSms = (data: { message: string; user_ids: number[]; scheduled_at?: string }) =>
|
export type BulkSendApiResult =
|
||||||
http.post("/notifications/bulk-sms", data)
|
| { kind: "immediate"; data: BulkSendResult; message: string }
|
||||||
|
| { kind: "scheduled"; data: ScheduledNotification; message: string }
|
||||||
|
|
||||||
export const sendBulkEmail = (formData: FormData) =>
|
function parseBulkSendApiResponse(body: unknown, status: number): BulkSendApiResult {
|
||||||
http.post("/notifications/bulk-email", formData, {
|
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" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
})
|
})
|
||||||
|
return parseBulkSendApiResponse(res.data, res.status)
|
||||||
|
}
|
||||||
|
|
||||||
export const sendBulkPush = (formData: FormData) =>
|
export const sendBulkPush = async (formData: FormData): Promise<BulkSendApiResult> => {
|
||||||
http.post("/notifications/bulk-push", formData, {
|
const res = await http.post("/notifications/bulk-push", formData, {
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
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)),
|
||||||
|
}))
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { LessonPracticesPage } from "../pages/content-management/LessonPractices
|
||||||
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
import { ModuleDetailPage } from "../pages/content-management/ModuleDetailPage";
|
||||||
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
import { AddVideoFlow } from "../pages/content-management/AddVideoFlow";
|
||||||
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
import { AddPracticeFlow } from "../pages/content-management/AddPracticeFlow";
|
||||||
|
import { EditPracticeFlow } from "../pages/content-management/EditPracticeFlow";
|
||||||
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
|
import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
|
||||||
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
|
import { ProgramTypeSelectionPage } from "../pages/content-management/ProgramTypeSelectionPage";
|
||||||
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
import { ProgramDetailPage } from "../pages/content-management/ProgramDetailPage";
|
||||||
|
|
@ -33,6 +34,7 @@ import { CreateQuestionTypeFlow } from "../pages/content-management/CreateQuesti
|
||||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||||
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
import { NotificationsPage } from "../pages/notifications/NotificationsPage";
|
||||||
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
import { CreateNotificationPage } from "../pages/notifications/CreateNotificationPage";
|
||||||
|
import { ScheduledNotificationsPage } from "../pages/notifications/ScheduledNotificationsPage";
|
||||||
import { EmailTemplatesPage } from "../pages/notifications/EmailTemplatesPage";
|
import { EmailTemplatesPage } from "../pages/notifications/EmailTemplatesPage";
|
||||||
import { EmailTemplateDetailPage } from "../pages/notifications/EmailTemplateDetailPage";
|
import { EmailTemplateDetailPage } from "../pages/notifications/EmailTemplateDetailPage";
|
||||||
import { CreateEmailTemplatePage } from "../pages/notifications/CreateEmailTemplatePage";
|
import { CreateEmailTemplatePage } from "../pages/notifications/CreateEmailTemplatePage";
|
||||||
|
|
@ -216,6 +218,10 @@ export function AppRoutes() {
|
||||||
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
|
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
|
||||||
element={<LessonPracticesPage />}
|
element={<LessonPracticesPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/edit-practice/:practiceId"
|
||||||
|
element={<EditPracticeFlow />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/new-content/learn-english"
|
path="/new-content/learn-english"
|
||||||
element={<LearnEnglishPage />}
|
element={<LearnEnglishPage />}
|
||||||
|
|
@ -240,6 +246,18 @@ export function AppRoutes() {
|
||||||
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
|
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
|
||||||
element={<LessonPracticesPage />}
|
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
|
<Route
|
||||||
path="/new-content/learn-english/:level/courses/add-practice"
|
path="/new-content/learn-english/:level/courses/add-practice"
|
||||||
element={<AddPracticeFlow />}
|
element={<AddPracticeFlow />}
|
||||||
|
|
@ -262,6 +280,10 @@ export function AppRoutes() {
|
||||||
path="/notifications/create"
|
path="/notifications/create"
|
||||||
element={<CreateNotificationPage />}
|
element={<CreateNotificationPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notifications/scheduled"
|
||||||
|
element={<ScheduledNotificationsPage />}
|
||||||
|
/>
|
||||||
<Route path="/payments" element={<PaymentsPage />} />
|
<Route path="/payments" element={<PaymentsPage />} />
|
||||||
<Route path="/user-log" element={<UserLogPage />} />
|
<Route path="/user-log" element={<UserLogPage />} />
|
||||||
<Route path="/issues" element={<IssuesPage />} />
|
<Route path="/issues" element={<IssuesPage />} />
|
||||||
|
|
|
||||||
173
src/components/notifications/NotificationSchedulePicker.tsx
Normal file
173
src/components/notifications/NotificationSchedulePicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,7 @@ const navEntries: NavEntry[] = [
|
||||||
{ label: "Inbox", to: "/notifications", end: true },
|
{ label: "Inbox", to: "/notifications", end: true },
|
||||||
{ label: "Email templates", to: "/notifications/email-templates" },
|
{ label: "Email templates", to: "/notifications/email-templates" },
|
||||||
{ label: "Send notification", to: "/notifications/create" },
|
{ label: "Send notification", to: "/notifications/create" },
|
||||||
|
{ label: "Scheduled", to: "/notifications/scheduled" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export function AppLayout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-grayScale-100">
|
<div className="flex h-dvh min-h-screen overflow-hidden bg-grayScale-100">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isOpen={sidebarOpen}
|
isOpen={sidebarOpen}
|
||||||
isCollapsed={sidebarCollapsed}
|
isCollapsed={sidebarCollapsed}
|
||||||
|
|
@ -68,15 +68,18 @@ export function AppLayout() {
|
||||||
onClose={handleSidebarClose}
|
onClose={handleSidebarClose}
|
||||||
/>
|
/>
|
||||||
<div
|
<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]"
|
sidebarCollapsed ? "lg:ml-[88px]" : "lg:ml-[264px]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Topbar onSidebarToggle={handleSidebarToggle} />
|
<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 />
|
<Outlet />
|
||||||
</main>
|
</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">
|
<div className="flex items-center justify-center gap-1.5 text-xs text-grayScale-400">
|
||||||
<span>Powered by</span>
|
<span>Powered by</span>
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ export interface LearnEnglishDefinitionQuestionInput {
|
||||||
dynamicFieldValues: Record<string, string>
|
dynamicFieldValues: Record<string, string>
|
||||||
difficultyLevel?: QuestionDifficultyLevel
|
difficultyLevel?: QuestionDifficultyLevel
|
||||||
points?: number
|
points?: number
|
||||||
|
displayOrder?: number
|
||||||
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
||||||
trueFalseAnswerIsTrue?: boolean
|
trueFalseAnswerIsTrue?: boolean
|
||||||
shortAnswers?: string[]
|
shortAnswers?: string[]
|
||||||
|
|
|
||||||
173
src/lib/notificationBulk.ts
Normal file
173
src/lib/notificationBulk.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -104,14 +104,23 @@ export async function executePracticeCreation(
|
||||||
})
|
})
|
||||||
const setId = extractCreatedResourceId(setRes, "Could not create question set")
|
const setId = extractCreatedResourceId(setRes, "Could not create question set")
|
||||||
|
|
||||||
const toCreate = opts.questions.filter((q) => {
|
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)
|
const def = byId.get(q.questionTypeDefinitionId)
|
||||||
return def ? questionRowHasContent(q, def) : false
|
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
|
let displayOrder = 0
|
||||||
for (const q of toCreate) {
|
for (const { q } of toCreate) {
|
||||||
const def = byId.get(q.questionTypeDefinitionId)
|
const def = byId.get(q.questionTypeDefinitionId)
|
||||||
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
|
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
|
||||||
displayOrder += 1
|
displayOrder += 1
|
||||||
|
|
|
||||||
46
src/lib/practiceEditOrchestrator.ts
Normal file
46
src/lib/practiceEditOrchestrator.ts
Normal 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)
|
||||||
|
}
|
||||||
711
src/lib/practiceFullMapper.ts
Normal file
711
src/lib/practiceFullMapper.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -196,6 +196,7 @@ export function AddPracticeFlow() {
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
|
displayOrder: 1,
|
||||||
questionTypeDefinitionId: null as number | null,
|
questionTypeDefinitionId: null as number | null,
|
||||||
text: "",
|
text: "",
|
||||||
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||||
|
|
@ -291,7 +292,7 @@ export function AddPracticeFlow() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persona = personaFromId(selectedPersona, personas);
|
const persona = personaFromId(selectedPersona, personas);
|
||||||
const mappedQuestions = formData.questions.map((q) => ({
|
const mappedQuestions = formData.questions.map((q, index) => ({
|
||||||
questionText: String(q.text ?? "").trim(),
|
questionText: String(q.text ?? "").trim(),
|
||||||
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||||
difficultyLevel: (q.difficultyLevel ?? "EASY") as
|
difficultyLevel: (q.difficultyLevel ?? "EASY") as
|
||||||
|
|
@ -299,6 +300,10 @@ export function AddPracticeFlow() {
|
||||||
| "MEDIUM"
|
| "MEDIUM"
|
||||||
| "HARD",
|
| "HARD",
|
||||||
points: Number.isFinite(Number(q.points)) ? Number(q.points) : 1,
|
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 ?? {}) },
|
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||||
mcqOptions: (q.mcqOptions ?? []).map(
|
mcqOptions: (q.mcqOptions ?? []).map(
|
||||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||||
|
|
@ -413,6 +418,7 @@ export function AddPracticeFlow() {
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
|
displayOrder: 1,
|
||||||
questionTypeDefinitionId:
|
questionTypeDefinitionId:
|
||||||
typeDefinitions[0]?.id ?? (null as number | null),
|
typeDefinitions[0]?.id ?? (null as number | null),
|
||||||
text: "",
|
text: "",
|
||||||
|
|
@ -573,7 +579,7 @@ export function AddPracticeFlow() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="mx-auto max-w-7xl w-full">
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -867,7 +867,9 @@ export function CourseDetailPage() {
|
||||||
practice={practice}
|
practice={practice}
|
||||||
statusUpdating={publishStatusPracticeId === practice.id}
|
statusUpdating={publishStatusPracticeId === practice.id}
|
||||||
onEdit={() =>
|
onEdit={() =>
|
||||||
navigate(`/content/practices?type=course&id=${courseIdNum}`)
|
navigate(
|
||||||
|
`/new-content/learn-english/${programIdParam}/courses/${courseIdNum}/edit-practice/${practice.id}?backTo=courses`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onPublish={() =>
|
onPublish={() =>
|
||||||
void handlePracticePublishStatus(
|
void handlePracticePublishStatus(
|
||||||
|
|
|
||||||
646
src/pages/content-management/EditPracticeFlow.tsx
Normal file
646
src/pages/content-management/EditPracticeFlow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
|
Edit2,
|
||||||
Hash,
|
Hash,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|
@ -91,6 +92,7 @@ function PracticeCard({
|
||||||
practice,
|
practice,
|
||||||
index,
|
index,
|
||||||
total,
|
total,
|
||||||
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onTogglePublishStatus,
|
onTogglePublishStatus,
|
||||||
publishStatusUpdating,
|
publishStatusUpdating,
|
||||||
|
|
@ -98,6 +100,7 @@ function PracticeCard({
|
||||||
practice: ParentContextPractice;
|
practice: ParentContextPractice;
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
onEdit?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
|
onTogglePublishStatus?: (nextStatus: PracticePublishStatus) => void;
|
||||||
publishStatusUpdating?: boolean;
|
publishStatusUpdating?: boolean;
|
||||||
|
|
@ -201,7 +204,7 @@ function PracticeCard({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<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 />
|
<Hash className="h-3 w-3 opacity-70" aria-hidden />
|
||||||
Question set {practice.question_set_id}
|
Question set {practice.question_set_id}
|
||||||
|
|
@ -210,6 +213,18 @@ function PracticeCard({
|
||||||
<Clock className="h-3 w-3 opacity-70" aria-hidden />
|
<Clock className="h-3 w-3 opacity-70" aria-hidden />
|
||||||
{formatPracticeDate(practice.created_at)}
|
{formatPracticeDate(practice.created_at)}
|
||||||
</Badge>
|
</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>
|
</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/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)}`;
|
: `/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 (
|
const handlePracticePublishStatus = async (
|
||||||
practiceId: number,
|
practiceId: number,
|
||||||
nextStatus: PracticePublishStatus,
|
nextStatus: PracticePublishStatus,
|
||||||
|
|
@ -547,6 +572,7 @@ export function LessonPracticesPage() {
|
||||||
practice={p}
|
practice={p}
|
||||||
index={i}
|
index={i}
|
||||||
total={filteredPractices.length}
|
total={filteredPractices.length}
|
||||||
|
onEdit={() => void navigate(editPracticeHref(p.id))}
|
||||||
onDelete={
|
onDelete={
|
||||||
isExamPrep ? () => setPracticeToDelete(p) : undefined
|
isExamPrep ? () => setPracticeToDelete(p) : undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -657,7 +657,7 @@ export function ModuleDetailPage() {
|
||||||
statusUpdating={publishStatusPracticeId === practice.id}
|
statusUpdating={publishStatusPracticeId === practice.id}
|
||||||
onEdit={() =>
|
onEdit={() =>
|
||||||
navigate(
|
navigate(
|
||||||
`/content/practices?type=module&id=${moduleId}`,
|
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/edit-practice/${practice.id}?backTo=module`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onPublish={() =>
|
onPublish={() =>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ export type PracticeSequentialReviewProps = {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSaveDraft: () => void;
|
onSaveDraft: () => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
|
publishLabel?: string;
|
||||||
|
publishingLabel?: string;
|
||||||
sectionTitle?: string;
|
sectionTitle?: string;
|
||||||
sectionSubtitle?: string;
|
sectionSubtitle?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -136,6 +138,8 @@ export function PracticeSequentialReview({
|
||||||
onBack,
|
onBack,
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
onPublish,
|
onPublish,
|
||||||
|
publishLabel = "Publish Now",
|
||||||
|
publishingLabel = "Publishing…",
|
||||||
sectionTitle = "Create Practice Questions",
|
sectionTitle = "Create Practice Questions",
|
||||||
sectionSubtitle = "Define the dialogue flow and interactions for this scenario.",
|
sectionSubtitle = "Define the dialogue flow and interactions for this scenario.",
|
||||||
}: PracticeSequentialReviewProps) {
|
}: PracticeSequentialReviewProps) {
|
||||||
|
|
@ -280,7 +284,7 @@ export function PracticeSequentialReview({
|
||||||
{filledQuestions.length}
|
{filledQuestions.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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) => (
|
{filledQuestions.map((question, index) => (
|
||||||
<div key={question.id} className="space-y-3">
|
<div key={question.id} className="space-y-3">
|
||||||
<span className="text-sm font-bold text-grayScale-400">
|
<span className="text-sm font-bold text-grayScale-400">
|
||||||
|
|
@ -329,7 +333,7 @@ export function PracticeSequentialReview({
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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) => (
|
{filledQuestions.map((question, index) => (
|
||||||
<div key={question.id} className="space-y-3">
|
<div key={question.id} className="space-y-3">
|
||||||
<span className="text-sm font-bold text-grayScale-400">
|
<span className="text-sm font-bold text-grayScale-400">
|
||||||
|
|
@ -398,7 +402,7 @@ export function PracticeSequentialReview({
|
||||||
) : (
|
) : (
|
||||||
<Rocket className="h-4 w-4" />
|
<Rocket className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{saving ? "Publishing…" : "Publish Now"}
|
{saving ? publishingLabel : publishLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 { Button } from "../../../../components/ui/button";
|
||||||
import { Card } from "../../../../components/ui/card";
|
import { Card } from "../../../../components/ui/card";
|
||||||
import { Input } from "../../../../components/ui/input";
|
import { Input } from "../../../../components/ui/input";
|
||||||
|
|
@ -11,8 +38,111 @@ import {
|
||||||
legacyQuestionTypeFromDefinition,
|
legacyQuestionTypeFromDefinition,
|
||||||
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
} from "../../../../lib/learnEnglishDefinitionQuestion";
|
||||||
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
|
import { validateLearnEnglishQuestionsWithDefinitions } from "../../../../lib/learnEnglishPracticePublish";
|
||||||
|
import { cn } from "../../../../lib/utils";
|
||||||
import { toast } from "sonner";
|
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() {
|
function defaultMcqOptions() {
|
||||||
return [
|
return [
|
||||||
{ text: "", isCorrect: true },
|
{ text: "", isCorrect: true },
|
||||||
|
|
@ -22,9 +152,11 @@ function defaultMcqOptions() {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyQuestionRow(id: string) {
|
function createEmptyQuestionRow(id: string, displayOrder = 1) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
displayOrder,
|
||||||
|
serverQuestionId: null as number | null,
|
||||||
questionTypeDefinitionId: null as number | null,
|
questionTypeDefinitionId: null as number | null,
|
||||||
text: "",
|
text: "",
|
||||||
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||||
|
|
@ -55,6 +187,65 @@ export function QuestionsStep({
|
||||||
definitionsLoading,
|
definitionsLoading,
|
||||||
definitionsError,
|
definitionsError,
|
||||||
}: QuestionsStepProps) {
|
}: 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 = (
|
const applyDefinitionToQuestion = (
|
||||||
index: number,
|
index: number,
|
||||||
definitionId: number,
|
definitionId: number,
|
||||||
|
|
@ -93,8 +284,9 @@ export function QuestionsStep({
|
||||||
}
|
}
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
questions: [...formData.questions, row],
|
questions: syncQuestionDisplayOrders([...formData.questions, row]),
|
||||||
});
|
});
|
||||||
|
setExpandedQuestionIds(new Set([id]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
|
const renderTypeSpecificFields = (q: any, i: number, def: QuestionTypeDefinition) => {
|
||||||
|
|
@ -319,11 +511,36 @@ export function QuestionsStep({
|
||||||
<div className="space-y-1 px-2">
|
<div className="space-y-1 px-2">
|
||||||
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
|
<h2 className="text-2xl font-bold text-grayScale-700">Questions</h2>
|
||||||
<p className="text-grayScale-400 text-lg">
|
<p className="text-grayScale-400 text-lg">
|
||||||
Choose a question type for each item, then fill in the fields that type requires. Questions are saved
|
Choose a question type for each item, then fill in the fields that type requires. Collapse cards to
|
||||||
when you publish or save the practice.
|
compare and drag them into order. Questions are saved when you publish or save the practice.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? (
|
{definitionsError ? (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
{definitionsError}
|
{definitionsError}
|
||||||
|
|
@ -334,33 +551,111 @@ export function QuestionsStep({
|
||||||
<p className="px-2 text-sm text-grayScale-500">Loading question types…</p>
|
<p className="px-2 text-sm text-grayScale-500">Loading question types…</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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) => {
|
{formData.questions.map((q: any, i: number) => {
|
||||||
const def = typeDefinitions.find(
|
const def = typeDefinitions.find(
|
||||||
(d) => d.id === q.questionTypeDefinitionId,
|
(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 (
|
return (
|
||||||
|
<SortableQuestionCard key={q.id} id={q.id}>
|
||||||
|
{({ dragHandleProps, isDragging }) => (
|
||||||
<Card
|
<Card
|
||||||
key={q.id}
|
className={cn(
|
||||||
className="relative overflow-hidden rounded-2xl border border-grayScale-50 bg-white shadow-soft"
|
"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="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="pl-7">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-grayScale-50 pb-4">
|
<div
|
||||||
<span className="text-base font-bold text-grayScale-500">
|
className={cn(
|
||||||
Question {i + 1}
|
"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>
|
||||||
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
type="button"
|
||||||
className="text-brand-500 hover:bg-brand-50 rounded-lg"
|
className="shrink-0 text-brand-500 hover:bg-brand-50 rounded-lg"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newQuestions = formData.questions.filter(
|
const newQuestions = formData.questions.filter(
|
||||||
(item: any) => item.id !== q.id,
|
(item: any) => item.id !== q.id,
|
||||||
);
|
);
|
||||||
|
setExpandedQuestionIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(q.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
if (newQuestions.length > 0) {
|
if (newQuestions.length > 0) {
|
||||||
setFormData({ ...formData, questions: newQuestions });
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
questions: syncQuestionDisplayOrders(newQuestions),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = createEmptyQuestionRow("q1");
|
const row = createEmptyQuestionRow("q1");
|
||||||
|
|
@ -371,13 +666,19 @@ export function QuestionsStep({
|
||||||
typeDefinitions[0],
|
typeDefinitions[0],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setFormData({ ...formData, questions: [row] });
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
questions: syncQuestionDisplayOrders([row]),
|
||||||
|
});
|
||||||
|
setExpandedQuestionIds(new Set(["q1"]));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
|
|
@ -486,9 +787,48 @@ export function QuestionsStep({
|
||||||
|
|
||||||
{def ? renderTypeSpecificFields(q, i, def) : null}
|
{def ? renderTypeSpecificFields(q, i, def) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</QuestionCollapsibleBody>
|
||||||
|
</div>
|
||||||
</Card>
|
</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">
|
<div className="flex items-center gap-8 pt-4">
|
||||||
<button
|
<button
|
||||||
|
|
@ -503,7 +843,6 @@ export function QuestionsStep({
|
||||||
Add question
|
Add question
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-8">
|
<div className="flex items-center justify-between pt-8">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ interface ReviewStepProps {
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
onSaveDraft: () => void;
|
onSaveDraft: () => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
|
publishLabel?: string;
|
||||||
|
publishingLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewStep({
|
export function ReviewStep({
|
||||||
|
|
@ -58,6 +60,8 @@ export function ReviewStep({
|
||||||
submitting,
|
submitting,
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
onPublish,
|
onPublish,
|
||||||
|
publishLabel,
|
||||||
|
publishingLabel,
|
||||||
}: ReviewStepProps) {
|
}: ReviewStepProps) {
|
||||||
const persona = personaFromId(selectedPersona, personas);
|
const persona = personaFromId(selectedPersona, personas);
|
||||||
|
|
||||||
|
|
@ -114,6 +118,8 @@ export function ReviewStep({
|
||||||
onBack={prevStep}
|
onBack={prevStep}
|
||||||
onSaveDraft={onSaveDraft}
|
onSaveDraft={onSaveDraft}
|
||||||
onPublish={onPublish}
|
onPublish={onPublish}
|
||||||
|
publishLabel={publishLabel}
|
||||||
|
publishingLabel={publishingLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,254 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
import { Bell, Mail, MailOpen, Megaphone } from "lucide-react"
|
import { Bell, CalendarClock, Mail, MailOpen, Megaphone, Search, Smartphone } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
import { Textarea } from "../../components/ui/textarea"
|
||||||
|
import { Select } from "../../components/ui/select"
|
||||||
import { FileUpload } from "../../components/ui/file-upload"
|
import { FileUpload } from "../../components/ui/file-upload"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
|
import { NotificationSchedulePicker } from "../../components/notifications/NotificationSchedulePicker"
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { getTeamMembers } from "../../api/team.api"
|
import {
|
||||||
import type { TeamMember } from "../../types/team.types"
|
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() {
|
export function CreateNotificationPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [composeChannels, setComposeChannels] = useState<Array<"push" | "sms">>(["push"])
|
const [channel, setChannel] = useState<NotificationChannel>("push")
|
||||||
const [composeAudience, setComposeAudience] = useState<"all" | "selected">("all")
|
const [audienceMode, setAudienceMode] = useState<AudienceMode>("role")
|
||||||
const [teamRecipients, setTeamRecipients] = useState<TeamMember[]>([])
|
const [sendMode, setSendMode] = useState<SendMode>("now")
|
||||||
|
const [platformRole, setPlatformRole] = useState<PlatformRole>("STUDENT")
|
||||||
|
const [users, setUsers] = useState<UserApiDTO[]>([])
|
||||||
const [recipientsLoading, setRecipientsLoading] = useState(false)
|
const [recipientsLoading, setRecipientsLoading] = useState(false)
|
||||||
const [selectedRecipientIds, setSelectedRecipientIds] = useState<number[]>([])
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([])
|
||||||
const [composeTitle, setComposeTitle] = useState("")
|
const [userSearchQuery, setUserSearchQuery] = useState("")
|
||||||
const [composeMessage, setComposeMessage] = 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 [sending, setSending] = useState(false)
|
||||||
const [, setComposeImage] = useState<File | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRecipientsLoading(true)
|
setRecipientsLoading(true)
|
||||||
getTeamMembers(1, 50)
|
fetchAllPlatformUsers()
|
||||||
.then((res) => {
|
.then(setUsers)
|
||||||
setTeamRecipients(res.data.data ?? [])
|
.catch(() => setUsers([]))
|
||||||
})
|
.finally(() => setRecipientsLoading(false))
|
||||||
.catch(() => {
|
|
||||||
setTeamRecipients([])
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setRecipientsLoading(false)
|
|
||||||
})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const selectedRecipients = useMemo(
|
const filteredUsers = useMemo(() => {
|
||||||
() => teamRecipients.filter((m) => selectedRecipientIds.includes(m.id)),
|
const q = userSearchQuery.trim().toLowerCase()
|
||||||
[teamRecipients, selectedRecipientIds],
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!composeTitle.trim() || !composeMessage.trim()) return
|
|
||||||
if (composeChannels.length === 0) return
|
if (channel !== "sms" && !title.trim()) {
|
||||||
setSending(true)
|
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 {
|
try {
|
||||||
// Hook up to backend send API here when available.
|
setSending(true)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
let result
|
||||||
setComposeTitle("")
|
|
||||||
setComposeMessage("")
|
if (channel === "sms") {
|
||||||
setComposeAudience("all")
|
result = await sendBulkSms({
|
||||||
setComposeChannels(["push"])
|
message: message.trim(),
|
||||||
setSelectedRecipientIds([])
|
...targeting,
|
||||||
setComposeImage(null)
|
...schedule,
|
||||||
navigate("/notifications")
|
} 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 {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewIcon = CHANNELS.find((c) => c.value === channel)?.icon ?? Bell
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl space-y-5">
|
<div className="mx-auto w-full max-w-5xl space-y-5">
|
||||||
{/* Breadcrumb + Header */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<nav className="flex items-center gap-1 text-xs text-grayScale-400">
|
<nav className="flex items-center gap-1 text-xs text-grayScale-400">
|
||||||
<button
|
<button
|
||||||
|
|
@ -84,7 +267,7 @@ export function CreateNotificationPage() {
|
||||||
Notifications
|
Notifications
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-grayScale-500">Create</span>
|
<span className="text-grayScale-500">Send</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|
@ -93,129 +276,136 @@ export function CreateNotificationPage() {
|
||||||
Notifications
|
Notifications
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-1 flex items-center gap-2 text-2xl font-semibold tracking-tight text-grayScale-700">
|
<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">
|
<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" />
|
<Megaphone className="h-3.5 w-3.5" />
|
||||||
Composer
|
Composer
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-xs text-grayScale-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/notifications/scheduled">Scheduled jobs</Link>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="self-start"
|
|
||||||
onClick={() => navigate("/notifications")}
|
onClick={() => navigate("/notifications")}
|
||||||
>
|
>
|
||||||
Back to notifications
|
Back to inbox
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1.1fr)]"
|
className="grid gap-4 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1.1fr)]"
|
||||||
>
|
>
|
||||||
{/* Left: message setup */}
|
|
||||||
<div className="space-y-4">
|
<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">
|
<CardContent className="space-y-4 p-4">
|
||||||
{/* Channel & audience */}
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||||
Channel
|
Channel
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
<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
|
<button
|
||||||
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
setComposeChannels((prev) =>
|
setChannel(value)
|
||||||
prev.includes("push")
|
if (value !== "sms" && value !== "email" && audienceMode === "direct") {
|
||||||
? prev.filter((c) => c !== "push")
|
setAudienceMode("role")
|
||||||
: [...prev, "push"],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (value === "sms") setTitle("")
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
||||||
composeChannels.includes("push")
|
channel === value
|
||||||
? "bg-brand-500 text-white shadow-sm"
|
? "bg-brand-500 text-white shadow-sm"
|
||||||
: "text-grayScale-500 hover:text-grayScale-700",
|
: "text-grayScale-500 hover:text-grayScale-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bell className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
Push
|
{label}
|
||||||
</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>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-grayScale-400">
|
||||||
Audience
|
Delivery
|
||||||
</p>
|
</p>
|
||||||
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
<div className="inline-flex rounded-full border border-grayScale-200 bg-grayScale-50 p-0.5 text-xs font-medium">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setComposeAudience("all")}
|
onClick={() => {
|
||||||
|
setSendMode("now")
|
||||||
|
setScheduledAt("")
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
"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"
|
? "bg-brand-500 text-white shadow-sm"
|
||||||
: "text-grayScale-500 hover:text-grayScale-700",
|
: "text-grayScale-500 hover:text-grayScale-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
All users
|
Send now
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setComposeAudience("selected")}
|
onClick={() => setSendMode("schedule")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 rounded-full px-3 py-1.5 transition-colors",
|
"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"
|
? "bg-brand-500 text-white shadow-sm"
|
||||||
: "text-grayScale-500 hover:text-grayScale-700",
|
: "text-grayScale-500 hover:text-grayScale-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Selected users
|
<CalendarClock className="h-3.5 w-3.5" />
|
||||||
|
Schedule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title & message */}
|
{isScheduling && (
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
Title
|
Scheduled at (UTC)
|
||||||
|
</label>
|
||||||
|
<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>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Short headline for this notification"
|
placeholder={
|
||||||
value={composeTitle}
|
channel === "email"
|
||||||
onChange={(e) => setComposeTitle(e.target.value)}
|
? "Email subject line"
|
||||||
|
: "Short headline for this notification"
|
||||||
|
}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
Message
|
Message
|
||||||
|
|
@ -223,73 +413,112 @@ export function CreateNotificationPage() {
|
||||||
<Textarea
|
<Textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder={
|
placeholder={
|
||||||
composeChannels.includes("sms") && !composeChannels.includes("push")
|
channel === "sms"
|
||||||
? "Concise SMS body. Keep it clear and under 160 characters where possible."
|
? "SMS body text."
|
||||||
: "Notification body shown inside the app."
|
: "Notification body shown to recipients."
|
||||||
}
|
}
|
||||||
value={composeMessage}
|
value={message}
|
||||||
onChange={(e) => setComposeMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Image upload */}
|
{supportsAttachment && (
|
||||||
<Card className="shadow-none border border-grayScale-100">
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
<CardContent className="space-y-2 p-4">
|
<CardContent className="space-y-2 p-4">
|
||||||
<p className="mb-1 block text-xs font-medium text-grayScale-500">
|
<p className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
Image (push only)
|
{channel === "push" ? "Image (push only)" : "Attachment (email only)"}
|
||||||
</p>
|
</p>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
accept="image/*"
|
accept={channel === "push" ? "image/*" : undefined}
|
||||||
onFileSelect={setComposeImage}
|
onFileSelect={setAttachment}
|
||||||
label="Upload notification image"
|
label={channel === "push" ? "Upload notification image" : "Upload attachment"}
|
||||||
description="Shown with push notification where supported"
|
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"
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer actions */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
|
||||||
<p className="text-[11px] text-grayScale-400">
|
<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>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button type="button" variant="outline" size="sm" onClick={resetForm} disabled={sending}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setComposeTitle("")
|
|
||||||
setComposeMessage("")
|
|
||||||
setComposeAudience("all")
|
|
||||||
setComposeChannels(["push"])
|
|
||||||
setSelectedRecipientIds([])
|
|
||||||
setComposeImage(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={sending || !composeTitle.trim() || !composeMessage.trim()}
|
disabled={
|
||||||
|
sending ||
|
||||||
|
(!message.trim() && !(channel === "email" && htmlBody.trim()))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<>
|
<>
|
||||||
<SpinnerIcon className="mr-2 h-3.5 w-3.5" alt="" />
|
<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" />
|
<MailOpen className="mr-2 h-3.5 w-3.5" />
|
||||||
Send notification
|
{isScheduling ? "Schedule notification" : "Send notification"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -297,72 +526,174 @@ export function CreateNotificationPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: audience & preview */}
|
|
||||||
<div className="space-y-4">
|
<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">
|
<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</p>
|
||||||
<p className="text-xs font-semibold text-grayScale-600">Audience & channels</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">
|
||||||
<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">
|
<button
|
||||||
{composeChannels.join(" + ").toUpperCase() || "—"}
|
type="button"
|
||||||
</span>
|
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>
|
</div>
|
||||||
<div className="space-y-1 text-[11px] text-grayScale-500">
|
|
||||||
<p>
|
{audienceMode === "role" && (
|
||||||
<span className="font-semibold text-grayScale-600">Audience:</span>{" "}
|
<div>
|
||||||
{composeAudience === "all"
|
<label className="mb-1 block text-xs font-medium text-grayScale-500">
|
||||||
? "All users"
|
Platform role
|
||||||
: selectedRecipients.length === 0
|
</label>
|
||||||
? "No users selected yet"
|
<Select
|
||||||
: `${selectedRecipients.length} selected user${
|
value={platformRole}
|
||||||
selectedRecipients.length === 1 ? "" : "s"
|
onChange={(e) => setPlatformRole(e.target.value as PlatformRole)}
|
||||||
}`}
|
>
|
||||||
</p>
|
{PLATFORM_ROLES.map((r) => (
|
||||||
<p>
|
<option key={r.value} value={r.value}>
|
||||||
<span className="font-semibold text-grayScale-600">Channels:</span>{" "}
|
{r.label}
|
||||||
{composeChannels.length === 0
|
</option>
|
||||||
? "None selected"
|
))}
|
||||||
: composeChannels.map((c) => c.toUpperCase()).join(" + ")}
|
</Select>
|
||||||
|
<p className="mt-1 text-[10px] text-grayScale-400">
|
||||||
|
Sends to all users with this platform role.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="shadow-none border border-grayScale-100">
|
{audienceMode === "selected" && (
|
||||||
|
<Card className="border border-grayScale-100 shadow-none">
|
||||||
<CardContent className="space-y-2 p-4">
|
<CardContent className="space-y-2 p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-semibold text-grayScale-600">Selected users</p>
|
<p className="text-xs font-semibold text-grayScale-600">Selected users</p>
|
||||||
{composeAudience === "selected" && (
|
|
||||||
<span className="text-[10px] text-grayScale-400">
|
<span className="text-[10px] text-grayScale-400">
|
||||||
{selectedRecipients.length} selected
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{composeAudience === "all" ? (
|
</div>
|
||||||
<p className="text-[11px] text-grayScale-400">
|
<div className="max-h-64 space-y-1.5 overflow-y-auto p-2">
|
||||||
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 && (
|
{recipientsLoading && (
|
||||||
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
<div className="flex items-center justify-center py-6 text-xs text-grayScale-400">
|
||||||
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
|
<SpinnerIcon className="mr-2 h-4 w-4" alt="" />
|
||||||
Loading users…
|
Loading users…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!recipientsLoading && teamRecipients.length === 0 && (
|
{!recipientsLoading && users.length === 0 && (
|
||||||
<div className="py-4 text-center text-xs text-grayScale-400">
|
<div className="py-4 text-center text-xs text-grayScale-400">
|
||||||
No users available to select.
|
No users available to select.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!recipientsLoading && users.length > 0 && filteredUsers.length === 0 && (
|
||||||
|
<div className="py-4 text-center text-xs text-grayScale-400">
|
||||||
|
No users match “{userSearchQuery.trim()}”.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!recipientsLoading &&
|
{!recipientsLoading &&
|
||||||
teamRecipients.map((member) => {
|
filteredUsers.map((user) => {
|
||||||
const checked = selectedRecipientIds.includes(member.id)
|
const checked = selectedUserIds.includes(user.id)
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={member.id}
|
key={user.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs",
|
"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",
|
checked ? "bg-brand-50 text-brand-700" : "hover:bg-grayScale-100",
|
||||||
|
|
@ -373,46 +704,55 @@ export function CreateNotificationPage() {
|
||||||
className="h-3.5 w-3.5 rounded border-grayScale-300"
|
className="h-3.5 w-3.5 rounded border-grayScale-300"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedRecipientIds((prev) =>
|
setSelectedUserIds((prev) =>
|
||||||
e.target.checked
|
e.target.checked
|
||||||
? [...prev, member.id]
|
? [...prev, user.id]
|
||||||
: prev.filter((id) => id !== member.id),
|
: prev.filter((id) => id !== user.id),
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{member.first_name} {member.last_name}
|
{user.first_name} {user.last_name}
|
||||||
<span className="ml-1 text-[10px] text-grayScale-400">
|
<span className="ml-1 text-[10px] text-grayScale-400">
|
||||||
· {member.email}
|
· {user.email ?? user.phone_number ?? `ID ${user.id}`}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mobile preview card */}
|
<Card className="border border-dashed border-grayScale-200 bg-grayScale-50/40 shadow-none">
|
||||||
<Card className="shadow-none border border-dashed border-grayScale-200 bg-grayScale-50/40">
|
|
||||||
<CardContent className="space-y-2 p-4">
|
<CardContent className="space-y-2 p-4">
|
||||||
<p className="text-xs font-semibold text-grayScale-600">Preview</p>
|
<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="space-y-1 rounded-xl border border-grayScale-200 bg-white p-3 text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-xs font-semibold text-grayScale-800">
|
<p className="truncate text-xs font-semibold text-grayScale-800">
|
||||||
{composeTitle || "Notification title"}
|
{title || (channel === "sms" ? "SMS message" : "Notification title")}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-grayScale-500">
|
<p className="truncate text-[11px] text-grayScale-500">
|
||||||
{composeMessage || "Message preview will appear here."}
|
{message || "Message preview will appear here."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-grayScale-400">
|
||||||
|
Channel: {channel.toUpperCase().replace("_", "-")}
|
||||||
|
{isScheduling && scheduledAt
|
||||||
|
? ` · Scheduled ${formatScheduledAtLabel(scheduledAt)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -420,4 +760,3 @@ export function CreateNotificationPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,29 @@
|
||||||
import { useEffect, useState, useCallback, useMemo } from "react"
|
import { useEffect, useState, useCallback } from "react"
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Megaphone,
|
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Mail,
|
Mail,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
MailX,
|
MailX,
|
||||||
Search,
|
Search,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Calendar,
|
|
||||||
Clock3,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Card, CardContent } from "../../components/ui/card"
|
import { Card, CardContent } from "../../components/ui/card"
|
||||||
import { Badge } from "../../components/ui/badge"
|
import { Badge } from "../../components/ui/badge"
|
||||||
import { Button } from "../../components/ui/button"
|
import { Button } from "../../components/ui/button"
|
||||||
import { Input } from "../../components/ui/input"
|
import { Input } from "../../components/ui/input"
|
||||||
import { Textarea } from "../../components/ui/textarea"
|
|
||||||
import { Select } from "../../components/ui/select"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "../../components/ui/dialog"
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../../components/ui/dropdown-menu"
|
} from "../../components/ui/dropdown-menu"
|
||||||
import { FileUpload } from "../../components/ui/file-upload"
|
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "../../lib/utils"
|
||||||
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
import { SpinnerIcon } from "../../components/ui/spinner-icon"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
@ -52,9 +35,6 @@ import {
|
||||||
markAsUnread,
|
markAsUnread,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
markAllUnread,
|
markAllUnread,
|
||||||
sendBulkSms,
|
|
||||||
sendBulkEmail,
|
|
||||||
sendBulkPush,
|
|
||||||
} from "../../api/notifications.api"
|
} from "../../api/notifications.api"
|
||||||
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
|
import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog"
|
||||||
import {
|
import {
|
||||||
|
|
@ -64,20 +44,10 @@ import {
|
||||||
getNotificationLevelBadge,
|
getNotificationLevelBadge,
|
||||||
NOTIFICATION_TYPE_CONFIG,
|
NOTIFICATION_TYPE_CONFIG,
|
||||||
} from "../../lib/notificationDisplay"
|
} 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 { 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 { toast } from "sonner"
|
||||||
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
|
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({
|
function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
onToggleRead,
|
onToggleRead,
|
||||||
|
|
@ -220,152 +190,6 @@ export function NotificationsPage() {
|
||||||
const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
|
const [typeFilter, setTypeFilter] = useState<"all" | string>("all")
|
||||||
const [levelFilter, setLevelFilter] = 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) => {
|
const fetchData = useCallback(async (currentOffset: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(false)
|
setError(false)
|
||||||
|
|
@ -992,487 +816,6 @@ export function NotificationsPage() {
|
||||||
: undefined
|
: 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
318
src/pages/notifications/ScheduledNotificationsPage.tsx
Normal file
318
src/pages/notifications/ScheduledNotificationsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -633,6 +633,103 @@ export interface UpdateParentLinkedPracticeResponse {
|
||||||
metadata: unknown | null
|
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). */
|
/** Body for PUT /lessons/:id (Learn English top-level module lessons). */
|
||||||
export interface UpdateTopLevelModuleLessonRequest {
|
export interface UpdateTopLevelModuleLessonRequest {
|
||||||
title: string
|
title: string
|
||||||
|
|
|
||||||
|
|
@ -55,3 +55,92 @@ export interface GetNotificationsResponse {
|
||||||
export interface UnreadCountResponse {
|
export interface UnreadCountResponse {
|
||||||
unread: number
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user