Compare commits

...

15 Commits
el-ui ... main

Author SHA1 Message Date
b21c679e56 question creation UI fixes 2026-06-06 03:47:00 -07:00
095e690a68 practice creation UI fix 2026-06-05 10:31:53 -07:00
1014f4a72f feat(admin): notification details, question type library, and schema UX
Wire GET /notifications/:id for topbar and notifications page detail views, harden notification WebSocket lifecycle, paginate question type and app version lists from API, and expand dynamic question type schema labels and slot editing.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 06:54:58 -07:00
e75420e756 feat(admin): analytics user breakdowns, email templates, and team invites
Surface education, occupation, learning goals, and language challenges on the analytics page with normalized dashboard API parsing. Add email template management, accept-invite onboarding, and role-based team invitations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 10:21:55 -07:00
b8a73c73db feat(content): admin UX for forms, practices, lessons, and content hub
Remove description fields from course, unit, and module create/edit dialogs. Add unit sort order on create, lesson publish status and sort order, video duration on lesson cards, and personas API integration for Learn English practice flows.

Move Manage Question Types to the new content hub, add Reorder Content page with hierarchy drag-and-drop, shared practice review UI, module practice cards, and publish-practice controls on course listings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 08:00:31 -07:00
38550f9519 UAT fixes stage 2 2026-05-19 04:41:43 -07:00
385f58fd22 UAT fixes stage 1 2026-05-18 08:44:51 -07:00
2b556d9d09 feat(content): lesson practices page, dynamic question schema, and practice flow updates
- Add LessonPracticesPage with GET /lessons/:id/practices and polished UI
- Route and module lesson navigation; view practices icon on VideoCard hover
- Question type definitions API, DynamicSchemaSlotField, definition helpers
- AddPracticeFlow and practice steps; AddQuestionPage and PracticeQuestionEditorFields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 09:30:53 -07:00
f1b6172f91 fix(content): uniform lesson card heights and wire descriptions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 05:34:50 -07:00
bb6680acfb feat(module): Add practice button on each lesson card
- VideoCard: optional onAddPractice for lesson-scoped CTA
- ModuleDetailPage: navigate to add-practice with lessonId and lessonTitle
- AddPracticeFlow: show context banner when opened from a lesson

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 05:27:06 -07:00
77b71abfd8 feat(practices): DYNAMIC questions with schema-driven payload
- Add practiceDynamicQuestionPayload helper to build stimulus/response slots
- Extend PracticeQuestionEditorFields with DYNAMIC type, definition picker, and per-slot values (JSON-capable)
- Wire AddNewPracticePage and AddNewLessonPage createQuestion to send question_type_definition_id and dynamic_payload
- Use lesson/practice save status (DRAFT/PUBLISHED) for created questions instead of always PUBLISHED

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 05:11:48 -07:00
9b35a8bf30 qustion type builder integartion fix 2026-05-13 04:38:09 -07:00
457d09f02b dynamic question type builder integration 2026-05-11 07:42:57 -07:00
168 changed files with 22321 additions and 4669 deletions

View File

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

82
package-lock.json generated
View File

@ -26,6 +26,7 @@
"react-is": "^19.2.5", "react-is": "^19.2.5",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"resend": "^6.12.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
@ -92,6 +93,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -360,6 +362,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -2670,6 +2673,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -2810,6 +2819,7 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -2820,6 +2830,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -2830,6 +2841,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -2885,6 +2897,7 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0", "@typescript-eslint/types": "8.50.0",
@ -3136,6 +3149,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3374,6 +3388,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -3950,6 +3965,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -4185,6 +4201,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.19.1", "version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -5064,6 +5086,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -5091,6 +5114,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/postal-mime": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
"license": "MIT-0"
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -5111,6 +5140,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -5299,6 +5329,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -5308,6 +5339,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -5319,13 +5351,15 @@
"version": "19.2.5", "version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
"integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-redux": { "node_modules/react-redux": {
"version": "9.2.0", "version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -5531,7 +5565,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -5548,6 +5583,27 @@
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/resend": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.12.3.tgz",
"integrity": "sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.4",
"svix": "1.92.2"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -5721,6 +5777,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -5783,6 +5849,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svix": {
"version": "1.92.2",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.92.2.tgz",
"integrity": "sha512-ZmuA3UVvlnF9EgxlzmPtF7CKjQb64Z6OFlyfdDfU0sdcC7dJa+3aOYX5B9mA+RS6ch1AxBa4UP/l6KmqfGtWBQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0"
}
},
"node_modules/tailwind-merge": { "node_modules/tailwind-merge": {
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
@ -5935,6 +6010,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -6102,6 +6178,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -6239,6 +6316,7 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@ -28,6 +28,7 @@
"react-is": "^19.2.5", "react-is": "^19.2.5",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"resend": "^6.12.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"

View File

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

View File

@ -1,5 +1,177 @@
import http from "./http"; import http from "./http";
import type { DashboardResponse } from "../types/analytics.types"; import type {
DashboardData,
DashboardFilters,
DashboardUsers,
DateCount,
LabelCount,
} from "../types/analytics.types";
export const getDashboard = () => function buildDashboardQueryParams(filters?: DashboardFilters): Record<string, string | number> {
http.get<DashboardResponse>("/analytics/dashboard"); if (!filters || filters.mode === "all_time") {
return {};
}
if (filters.mode === "year" && filters.year != null) {
return { year: filters.year };
}
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
return { year: filters.year, month: filters.month };
}
if (filters.mode === "custom" && filters.from && filters.to) {
return { from: filters.from, to: filters.to };
}
return {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function pickField(record: Record<string, unknown>, ...keys: string[]): unknown {
for (const key of keys) {
if (key in record && record[key] != null) return record[key];
}
return undefined;
}
function asLabelCounts(value: unknown): LabelCount[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const label = String(pickField(item, "label", "Label") ?? "").trim();
const count = Number(pickField(item, "count", "Count") ?? 0);
if (!label) return null;
return { label, count: Number.isFinite(count) ? count : 0 };
})
.filter((row): row is LabelCount => row !== null);
}
function asDateCounts(value: unknown): DateCount[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const date = String(pickField(item, "date", "Date") ?? "");
const count = Number(pickField(item, "count", "Count") ?? 0);
if (!date) return null;
return { date, count: Number.isFinite(count) ? count : 0 };
})
.filter((row): row is DateCount => row !== null);
}
function isDashboardPayload(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false;
return (
"generated_at" in value ||
"generatedAt" in value ||
"users" in value ||
"Users" in value
);
}
/** Unwrap `{ data }` / `{ Data }` envelopes until the dashboard object is found. */
function unwrapDashboardPayload(body: unknown): Record<string, unknown> {
let current: unknown = body;
for (let depth = 0; depth < 5; depth++) {
if (!isRecord(current)) break;
if (isDashboardPayload(current)) {
const nested = pickField(current, "data", "Data");
if (isRecord(nested) && isDashboardPayload(nested)) {
current = nested;
continue;
}
return current;
}
const inner = pickField(current, "data", "Data");
if (inner != null) {
current = inner;
continue;
}
break;
}
return isRecord(current) ? current : {};
}
const EDUCATION_LEVEL_KEYS = ["by_education_level", "byEducationLevel", "ByEducationLevel"] as const;
const OCCUPATION_KEYS = ["by_occupation", "byOccupation", "ByOccupation"] as const;
const LEARNING_GOAL_KEYS = ["by_learning_goal", "byLearningGoal", "ByLearningGoal"] as const;
const LANGUAGE_CHALLENGE_KEYS = [
"by_language_challange",
"by_language_challenge",
"byLanguageChallange",
"byLanguageChallenge",
"ByLanguageChallange",
"ByLanguageChallenge",
] as const;
function normalizeDashboardUsers(raw: unknown, root?: Record<string, unknown>): DashboardUsers {
const u = isRecord(raw) ? raw : {};
const scope = root ?? u;
return {
total_users: Number(pickField(u, "total_users", "totalUsers", "TotalUsers") ?? 0),
new_today: Number(pickField(u, "new_today", "newToday", "NewToday") ?? 0),
new_week: Number(pickField(u, "new_week", "newWeek", "NewWeek") ?? 0),
new_month: Number(pickField(u, "new_month", "newMonth", "NewMonth") ?? 0),
by_role: asLabelCounts(pickField(u, "by_role", "byRole", "ByRole")),
by_status: asLabelCounts(pickField(u, "by_status", "byStatus", "ByStatus")),
by_age_group: asLabelCounts(pickField(u, "by_age_group", "byAgeGroup", "ByAgeGroup")),
by_education_level: asLabelCounts(
pickField(u, ...EDUCATION_LEVEL_KEYS) ?? pickField(scope, ...EDUCATION_LEVEL_KEYS),
),
by_occupation: asLabelCounts(
pickField(u, ...OCCUPATION_KEYS) ?? pickField(scope, ...OCCUPATION_KEYS),
),
by_learning_goal: asLabelCounts(
pickField(u, ...LEARNING_GOAL_KEYS) ?? pickField(scope, ...LEARNING_GOAL_KEYS),
),
by_language_challange: asLabelCounts(
pickField(u, ...LANGUAGE_CHALLENGE_KEYS) ?? pickField(scope, ...LANGUAGE_CHALLENGE_KEYS),
),
by_knowledge_level: asLabelCounts(
pickField(u, "by_knowledge_level", "byKnowledgeLevel", "ByKnowledgeLevel"),
),
by_country: asLabelCounts(pickField(u, "by_country", "byCountry", "ByCountry")),
by_region: asLabelCounts(pickField(u, "by_region", "byRegion", "ByRegion")),
registrations_last_30_days: asDateCounts(
pickField(
u,
"registrations_last_30_days",
"registrationsLast30Days",
"RegistrationsLast30Days",
),
),
};
}
function normalizeDashboardResponse(body: unknown): DashboardData {
const root = unwrapDashboardPayload(body);
const usersRaw = pickField(root, "users", "Users");
return {
...(root as DashboardData),
users: normalizeDashboardUsers(usersRaw, root),
};
}
export const getDashboard = (filters?: DashboardFilters) =>
http
.get<unknown>("/analytics/dashboard", {
params: buildDashboardQueryParams(filters),
})
.then((res) => ({
...res,
data: normalizeDashboardResponse(res.data),
}));

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

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

View File

@ -97,6 +97,9 @@ import type {
CreateExamPrepModuleLessonResponse, CreateExamPrepModuleLessonResponse,
UpdateExamPrepModuleLessonRequest, UpdateExamPrepModuleLessonRequest,
UpdateExamPrepModuleLessonResponse, UpdateExamPrepModuleLessonResponse,
PublishExamPrepModuleLessonRequest,
CreateExamPrepLessonPracticeRequest,
CreateExamPrepLessonPracticeResponse,
GetExamPrepModuleLessonsResponse, GetExamPrepModuleLessonsResponse,
GetTopLevelModuleLessonsResponse, GetTopLevelModuleLessonsResponse,
GetPracticesByParentContextResponse, GetPracticesByParentContextResponse,
@ -104,7 +107,9 @@ import type {
CreateParentLinkedPracticeResponse, CreateParentLinkedPracticeResponse,
UpdateParentLinkedPracticeRequest, UpdateParentLinkedPracticeRequest,
UpdateParentLinkedPracticeResponse, UpdateParentLinkedPracticeResponse,
PublishParentLinkedPracticeRequest,
UpdateTopLevelModuleLessonRequest, UpdateTopLevelModuleLessonRequest,
PublishTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonRequest, CreateTopLevelModuleLessonRequest,
CreateTopLevelModuleLessonResponse, CreateTopLevelModuleLessonResponse,
} from "../types/course.types" } from "../types/course.types"
@ -585,10 +590,26 @@ export const updateExamPrepModuleLesson = (
data, data,
) )
/** PUT /exam-prep/lessons/:lessonId — set publish_status only (draft or published). */
export const publishExamPrepModuleLesson = (
lessonId: number,
data: PublishExamPrepModuleLessonRequest,
) => http.put(`/exam-prep/lessons/${lessonId}`, data)
/** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */ /** English proficiency lesson — DELETE /exam-prep/lessons/:lessonId */
export const deleteExamPrepModuleLesson = (lessonId: number) => export const deleteExamPrepModuleLesson = (lessonId: number) =>
http.delete(`/exam-prep/lessons/${lessonId}`) http.delete(`/exam-prep/lessons/${lessonId}`)
/** POST /exam-prep/lessons/:lessonId/practices */
export const createExamPrepLessonPractice = (
lessonId: number,
data: CreateExamPrepLessonPracticeRequest,
) =>
http.post<CreateExamPrepLessonPracticeResponse>(
`/exam-prep/lessons/${lessonId}/practices`,
data,
)
/** Top-level course resource (Learn English track) — PUT /courses/:id */ /** Top-level course resource (Learn English track) — PUT /courses/:id */
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) => export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
http.put(`/courses/${courseId}`, data) http.put(`/courses/${courseId}`, data)
@ -646,6 +667,12 @@ export const updateTopLevelModuleLesson = (
data: UpdateTopLevelModuleLessonRequest, data: UpdateTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data) ) => http.put(`/lessons/${lessonId}`, data)
/** PUT /lessons/:id — set publish_status only (draft or published). */
export const publishTopLevelModuleLesson = (
lessonId: number,
data: PublishTopLevelModuleLessonRequest,
) => http.put(`/lessons/${lessonId}`, data)
/** Learn English top-level module lesson — DELETE /lessons/:id */ /** Learn English top-level module lesson — DELETE /lessons/:id */
export const deleteTopLevelModuleLesson = (lessonId: number) => export const deleteTopLevelModuleLesson = (lessonId: number) =>
http.delete(`/lessons/${lessonId}`) http.delete(`/lessons/${lessonId}`)
@ -681,6 +708,12 @@ export const updateParentLinkedPractice = (
data: UpdateParentLinkedPracticeRequest, data: UpdateParentLinkedPracticeRequest,
) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data) ) => http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, data)
/** PUT /practices/:id — set publish_status (e.g. publish a draft). */
export const publishParentLinkedPractice = (practiceId: number) =>
http.put<UpdateParentLinkedPracticeResponse>(`/practices/${practiceId}`, {
publish_status: "PUBLISHED",
} satisfies PublishParentLinkedPracticeRequest)
/** DELETE /practices/:id */ /** DELETE /practices/:id */
export const deleteParentLinkedPractice = (practiceId: number) => export const deleteParentLinkedPractice = (practiceId: number) =>
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>( http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(

View File

@ -0,0 +1,69 @@
import http from "./http"
import type {
CreateEmailTemplateRequest,
CreateEmailTemplateResponse,
DeleteEmailTemplateResponse,
EmailTemplate,
GetEmailTemplateBySlugResponse,
GetEmailTemplatesResponse,
UpdateEmailTemplateRequest,
UpdateEmailTemplateResponse,
} from "../types/emailTemplate.types"
/** GET /admin/email-templates — list all email templates. */
export const getEmailTemplates = () =>
http.get<GetEmailTemplatesResponse>("/admin/email-templates")
/** GET /admin/email-templates/slug/:slug — single template by slug. */
export const getEmailTemplateBySlug = (slug: string) =>
http.get<GetEmailTemplateBySlugResponse>(
`/admin/email-templates/slug/${encodeURIComponent(slug)}`,
)
function normalizeEmailTemplate(row: unknown): EmailTemplate | null {
if (!row || typeof row !== "object" || !("slug" in row)) return null
const t = row as EmailTemplate
return {
...t,
variables: Array.isArray(t.variables) ? t.variables : [],
status: t.status ?? "ACTIVE",
updated_at: t.updated_at ?? t.created_at ?? "",
}
}
export function parseEmailTemplatesResponse(
response: Awaited<ReturnType<typeof getEmailTemplates>>,
): EmailTemplate[] {
const data = response.data?.data
const rows = Array.isArray(data)
? data
: Array.isArray(data?.templates)
? data.templates
: []
return rows
.map(normalizeEmailTemplate)
.filter((row): row is EmailTemplate => row != null)
}
/** PUT /admin/email-templates/:id — update subject and bodies. */
export const updateEmailTemplate = (
id: number,
data: UpdateEmailTemplateRequest,
) => http.put<UpdateEmailTemplateResponse>(`/admin/email-templates/${id}`, data)
/** POST /admin/email-templates — create a custom template. */
export const createEmailTemplate = (data: CreateEmailTemplateRequest) =>
http.post<CreateEmailTemplateResponse>("/admin/email-templates", data)
/** DELETE /admin/email-templates/:id — delete a custom template. */
export const deleteEmailTemplate = (id: number) =>
http.delete<DeleteEmailTemplateResponse>(`/admin/email-templates/${id}`)
export function parseEmailTemplateResponse(
response:
| Awaited<ReturnType<typeof getEmailTemplateBySlug>>
| Awaited<ReturnType<typeof updateEmailTemplate>>
| Awaited<ReturnType<typeof createEmailTemplate>>,
): EmailTemplate | null {
return normalizeEmailTemplate(response.data?.data)
}

View File

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

View File

@ -59,7 +59,9 @@ const isAuthEndpointRequest = (url?: string) => {
return ( return (
url.includes("/team/login") || url.includes("/team/login") ||
url.includes("/team/google-login") || url.includes("/team/google-login") ||
url.includes("/team/refresh") url.includes("/team/refresh") ||
url.includes("/team/invitations/verify") ||
url.includes("/team/invitations/accept")
); );
}; };

View File

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

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

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

6
src/api/personas.api.ts Normal file
View File

@ -0,0 +1,6 @@
import http from "./http"
import type { GetPersonasParams, GetPersonasResponse } from "../types/persona.types"
/** GET /personas — list personas (filter active client-side when needed). */
export const getPersonas = (params?: GetPersonasParams) =>
http.get<GetPersonasResponse>("/personas", { params })

View File

@ -0,0 +1,360 @@
import http from "./http"
import type {
DynamicElementDefinition,
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
QuestionTypeDefinitionUpdatePayload,
QuestionTypeDefinitionValidatePayload,
ValidateQuestionTypeDefinitionResult,
} from "../types/questionTypeDefinition.types"
interface ApiEnvelope<T> {
message?: string
data?: T
/** Some routes use PascalCase in JSON */
Data?: T
success?: boolean
status_code?: number
error?: string
}
/**
* Reads the inner payload from a typical API envelope, or the raw body.
* Supports `data` / `Data` and list bodies where `res.data` is already an array
* (e.g. GET /questions/type-definitions `data: [ { ID, Key, … }, … ]`).
*/
export function unwrapApiPayload(res: { data?: unknown }): unknown {
const body = res.data
if (body === null || body === undefined) return undefined
if (Array.isArray(body)) return body
if (typeof body !== "object") return body
const o = body as Record<string, unknown>
if ("data" in o || "Data" in o) {
const inner = o.data ?? o.Data
return inner
}
return body
}
function fromStringArray(arr: unknown): string[] {
return Array.isArray(arr)
? arr.filter((k): k is string => typeof k === "string" && k.length > 0)
: []
}
function sortUniqueStrings(values: string[]): string[] {
return [...new Set(values)].sort((a, b) => a.localeCompare(b))
}
const emptyCatalog = (): QuestionComponentCatalog => ({
stimulus_component_kinds: [],
response_component_kinds: [],
})
/**
* Parse GET /questions/component-catalog body.
* Canonical shape: `data.stimulus_component_kinds` + `data.response_component_kinds`.
*/
export function parseComponentCatalog(payload: unknown): QuestionComponentCatalog {
if (!payload || typeof payload !== "object") return emptyCatalog()
const d = payload as Record<string, unknown>
if (
Array.isArray(d.stimulus_component_kinds) ||
Array.isArray(d.response_component_kinds)
) {
return {
stimulus_component_kinds: sortUniqueStrings(fromStringArray(d.stimulus_component_kinds)),
response_component_kinds: sortUniqueStrings(fromStringArray(d.response_component_kinds)),
}
}
if (d.data !== undefined && d.data !== null && typeof d.data === "object") {
const inner = parseComponentCatalog(d.data)
if (
inner.stimulus_component_kinds.length > 0 ||
inner.response_component_kinds.length > 0
) {
return inner
}
}
const sk = fromStringArray(d.stimulus_kinds)
const rk = fromStringArray(d.response_kinds)
if (sk.length || rk.length) {
return {
stimulus_component_kinds: sortUniqueStrings(sk),
response_component_kinds: sortUniqueStrings(rk),
}
}
const mergedFlat = sortUniqueStrings([
...fromStringArray(d.kinds),
...fromStringArray(d.codes),
...fromStringArray(d.component_kinds),
])
if (mergedFlat.length) {
return {
stimulus_component_kinds: mergedFlat,
response_component_kinds: [...mergedFlat],
}
}
if (Array.isArray(payload)) {
const merged = sortUniqueStrings(fromStringArray(payload))
return {
stimulus_component_kinds: merged,
response_component_kinds: [...merged],
}
}
return emptyCatalog()
}
export async function getQuestionComponentCatalog(): Promise<QuestionComponentCatalog> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/component-catalog")
const raw = unwrapApiPayload(res) ?? res.data
return parseComponentCatalog(raw)
}
function parseInnerValidFlag(inner: unknown): boolean | undefined {
if (inner == null || typeof inner !== "object") return undefined
const o = inner as Record<string, unknown>
const v = o.valid ?? o.Valid
if (typeof v === "boolean") return v
if (v === "true" || v === 1) return true
if (v === "false" || v === 0) return false
return undefined
}
/**
* POST /questions/validate-question-type-definition
* Success: 200 with `data.valid` (envelope `success` may still be false).
* Invalid: 400 with `message` / `error` on the JSON body (axios throws).
*/
export async function validateQuestionTypeDefinition(
body: QuestionTypeDefinitionValidatePayload,
): Promise<ValidateQuestionTypeDefinitionResult> {
try {
const res = await http.post<ApiEnvelope<unknown>>("/questions/validate-question-type-definition", body)
const envelope = res.data as ApiEnvelope<unknown>
const inner = unwrapApiPayload(res)
const validFlag = parseInnerValidFlag(inner)
if (validFlag === true) {
return { valid: true, message: envelope?.message }
}
const envErr =
typeof envelope?.error === "string"
? envelope.error
: envelope && typeof envelope === "object" && "Error" in envelope
? String((envelope as { Error?: unknown }).Error)
: undefined
return {
valid: false,
message: envelope?.message,
error:
envErr ||
(validFlag === false ? "Definition is not valid." : "Validation response did not include a valid flag."),
}
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
const d = err.response?.data
return {
valid: false,
message: d?.message,
error: d?.error || d?.message || "Validation request failed",
}
}
}
export async function createQuestionTypeDefinition(body: QuestionTypeDefinitionCreatePayload) {
return http.post<ApiEnvelope<unknown>>("/questions/type-definitions", body)
}
function asStr(v: unknown): string {
if (v == null || v === undefined) return ""
return String(v)
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return []
return v.filter((x): x is string => typeof x === "string" && x.length > 0)
}
function normalizeSchemaRows(v: unknown): DynamicElementDefinition[] {
if (!Array.isArray(v)) return []
return v.map((row) => {
if (!row || typeof row !== "object") {
return { id: "", kind: "", required: false, label: undefined, config: undefined }
}
const r = row as Record<string, unknown>
const id = asStr(r.id ?? r.Id)
const kind = asStr(r.kind ?? r.Kind)
const labelRaw = r.label ?? r.Label
const config = (r.config ?? r.Config) as Record<string, unknown> | undefined
return {
id,
kind,
label: labelRaw != null && labelRaw !== "" ? asStr(labelRaw) : undefined,
required: Boolean(r.required ?? r.Required),
config: config && typeof config === "object" && !Array.isArray(config) ? config : undefined,
}
})
}
/**
* Maps GET/POST definition objects from PascalCase (ID, Key, StimulusSchema, )
* or snake_case into {@link QuestionTypeDefinition}.
*/
export function normalizeTypeDefinitionFromApi(raw: unknown): QuestionTypeDefinition | null {
if (!raw || typeof raw !== "object") return null
const o = raw as Record<string, unknown>
const id = Number(o.ID ?? o.id)
if (!Number.isFinite(id)) return null
const statusRaw = asStr(o.Status ?? o.status).toUpperCase()
const status = statusRaw === "INACTIVE" ? "INACTIVE" : "ACTIVE"
return {
id,
key: asStr(o.Key ?? o.key),
display_name: asStr(
o.DisplayName ?? o.display_name ?? o.displayName ?? o.Display_Name,
),
description: (() => {
const d = o.Description ?? o.description
if (d == null) return null
const s = asStr(d)
return s === "" ? null : s
})(),
stimulus_component_kinds: asStringArray(o.StimulusComponentKinds ?? o.stimulus_component_kinds),
response_component_kinds: asStringArray(o.ResponseComponentKinds ?? o.response_component_kinds),
stimulus_schema: normalizeSchemaRows(o.StimulusSchema ?? o.stimulus_schema),
response_schema: normalizeSchemaRows(o.ResponseSchema ?? o.response_schema),
status,
is_system: Boolean(o.IsSystem ?? o.is_system),
created_at: o.CreatedAt != null ? asStr(o.CreatedAt) : o.created_at != null ? asStr(o.created_at) : undefined,
updated_at: o.UpdatedAt != null ? asStr(o.UpdatedAt) : o.updated_at != null ? asStr(o.updated_at) : undefined,
}
}
/** Label for selects: API `DisplayName` (stored as `display_name`), then key, then id. */
export function questionTypeDefinitionListLabel(def: QuestionTypeDefinition): string {
const name = def.display_name?.trim()
if (name) return name
const k = def.key?.trim()
if (k) return k
return `Type #${def.id}`
}
/**
* Definition id from POST create or PUT update (`data.ID`, `data.id`, or PascalCase `Id`).
* Example update: `{ "data": { "id": 6 } }`.
*/
export function extractDefinitionMutationId(res: { data?: unknown }): number | undefined {
const data = unwrapApiPayload(res)
if (!data || typeof data !== "object" || Array.isArray(data)) return undefined
const o = data as Record<string, unknown>
const id = Number(o.ID ?? o.id ?? o.Id)
return Number.isFinite(id) && id > 0 ? id : undefined
}
/** @deprecated use extractDefinitionMutationId */
export const extractCreatedDefinitionId = extractDefinitionMutationId
export interface QuestionTypeDefinitionsListParams {
include_system?: boolean
status?: string
limit?: number
offset?: number
}
export interface QuestionTypeDefinitionsListResult {
definitions: QuestionTypeDefinition[]
total_count?: number
}
function parseListTotalCount(body: unknown): number | undefined {
if (!body || typeof body !== "object" || Array.isArray(body)) return undefined
const o = body as Record<string, unknown>
const direct = Number(o.total_count ?? o.TotalCount ?? o.totalCount)
if (Number.isFinite(direct) && direct >= 0) return direct
const meta = o.metadata ?? o.Metadata
if (meta && typeof meta === "object" && !Array.isArray(meta)) {
const m = meta as Record<string, unknown>
const fromMeta = Number(m.total_count ?? m.TotalCount ?? m.totalCount)
if (Number.isFinite(fromMeta) && fromMeta >= 0) return fromMeta
}
const data = o.data ?? o.Data
if (data && typeof data === "object" && !Array.isArray(data)) {
return parseListTotalCount(data)
}
return undefined
}
export function parseDefinitionsList(payload: unknown): QuestionTypeDefinition[] {
if (!payload) return []
if (Array.isArray(payload)) {
return payload
.map((item) => normalizeTypeDefinitionFromApi(item))
.filter((x): x is QuestionTypeDefinition => x != null)
}
if (typeof payload === "object" && payload !== null) {
const o = payload as Record<string, unknown>
const inner =
o.question_type_definitions ??
o.QuestionTypeDefinitions ??
o.definitions ??
o.items ??
o.rows ??
o.Definitions
if (Array.isArray(inner)) return parseDefinitionsList(inner)
if (inner && typeof inner === "object" && !Array.isArray(inner)) return parseDefinitionsList(inner)
const data = o.data ?? o.Data
if (Array.isArray(data)) return parseDefinitionsList(data)
if (data && typeof data === "object") return parseDefinitionsList(data)
const single = normalizeTypeDefinitionFromApi(payload)
return single ? [single] : []
}
return []
}
export async function getQuestionTypeDefinitions(
params?: QuestionTypeDefinitionsListParams,
): Promise<QuestionTypeDefinitionsListResult> {
const res = await http.get<ApiEnvelope<unknown>>("/questions/type-definitions", { params })
const raw = unwrapApiPayload(res) ?? res.data
const definitions = parseDefinitionsList(raw)
const total_count = parseListTotalCount(raw) ?? parseListTotalCount(res.data)
return total_count != null ? { definitions, total_count } : { definitions }
}
/**
* GET /questions/type-definitions/:id
*
* Typical success body (axios `res.data`): envelope with nested `data` or `Data` holding the definition.
* Definition fields are often PascalCase (`ID`, `Key`, `DisplayName`, `StimulusComponentKinds`, `StimulusSchema`,
* `ResponseSchema`, `IsSystem`, `Status`, `CreatedAt`, `UpdatedAt`). Envelope `success` may be false; parsing
* does not rely on it.
*/
export async function getQuestionTypeDefinitionById(id: number): Promise<QuestionTypeDefinition | undefined> {
const res = await http.get<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
const fromEnvelope = unwrapApiPayload(res)
return (
normalizeTypeDefinitionFromApi(fromEnvelope) ?? normalizeTypeDefinitionFromApi(res.data) ?? undefined
)
}
export async function updateQuestionTypeDefinition(
id: number,
body: QuestionTypeDefinitionUpdatePayload,
) {
return http.put<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`, body)
}
export async function deleteQuestionTypeDefinition(id: number) {
return http.delete<ApiEnvelope<unknown>>(`/questions/type-definitions/${id}`)
}

View File

@ -8,6 +8,8 @@ import type {
DeleteRoleResponse, DeleteRoleResponse,
SetRolePermissionsRequest, SetRolePermissionsRequest,
GetPermissionsResponse, GetPermissionsResponse,
BulkRoleDeactivateResponse,
BulkRoleReactivateResponse,
} from "../types/rbac.types" } from "../types/rbac.types"
export const getRoles = (params?: GetRolesParams) => export const getRoles = (params?: GetRolesParams) =>
@ -30,3 +32,11 @@ export const getAllPermissions = () =>
export const deleteRole = (roleId: number) => export const deleteRole = (roleId: number) =>
http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`) http.delete<DeleteRoleResponse>(`/rbac/roles/${roleId}`)
/** Deactivate all users and team members tied to this role (admin). */
export const bulkDeactivateRole = (roleId: number) =>
http.post<BulkRoleDeactivateResponse>(`/admin/roles/${roleId}/bulk-deactivate`, {})
/** Reactivate users and team members tied to this role (admin). */
export const bulkReactivateRole = (roleId: number) =>
http.post<BulkRoleReactivateResponse>(`/admin/roles/${roleId}/bulk-reactivate`, {})

View File

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

View File

@ -1,5 +1,14 @@
import http from "./http" import http from "./http"
import type { import type {
AcceptInvitationRequest,
AcceptInvitationResponse,
InviteTeamMemberRequest,
InviteTeamMemberResponse,
VerifyInvitationResponse,
} from "../types/teamInvitation.types"
import type {
ChangeTeamMemberPasswordRequest,
ChangeTeamMemberPasswordResponse,
GetTeamMembersResponse, GetTeamMembersResponse,
GetTeamMemberResponse, GetTeamMemberResponse,
CreateTeamMemberRequest, CreateTeamMemberRequest,
@ -25,3 +34,31 @@ export const updateTeamMemberStatus = (id: number, status: string) =>
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) => export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
http.put(`/team/members/${id}`, data) http.put(`/team/members/${id}`, data)
/** POST /team/members/:id/change-password — change the signed-in member's password. */
export const changeTeamMemberPassword = (id: number, data: ChangeTeamMemberPasswordRequest) =>
http.post<ChangeTeamMemberPasswordResponse>(`/team/members/${id}/change-password`, data)
/** POST /team/members/invite — send invitation email (permission: team.members.invite). */
export const inviteTeamMember = (data: InviteTeamMemberRequest) =>
http.post<InviteTeamMemberResponse>("/team/members/invite", data)
/** GET /team/invitations/verify?token= — public (accept-invite page). */
export const verifyTeamInvitation = (token: string) =>
http.get<VerifyInvitationResponse>("/team/invitations/verify", {
params: { token },
})
/** POST /team/invitations/accept — public (set password after invite). */
export const acceptTeamInvitation = (data: AcceptInvitationRequest) =>
http.post<AcceptInvitationResponse>("/team/invitations/accept", data)
export function parseVerifyInvitation(
response: Awaited<ReturnType<typeof verifyTeamInvitation>>,
): VerifyInvitationResponse["data"] | null {
const body = response.data
if (body?.data && typeof body.data === "object" && "valid" in body.data) {
return body.data
}
return null
}

View File

@ -6,23 +6,46 @@ import {
type UserSummaryResponse, type UserSummaryResponse,
type GetDeletionRequestsParams, type GetDeletionRequestsParams,
type GetDeletionRequestsResponse, type GetDeletionRequestsResponse,
type UserRecentActivityResponse,
} from "../types/user.types"; } from "../types/user.types";
export const getUsers = ( /** Query params for GET /users (RFC3339 for created_*; subscription_status: ACTIVE | PENDING | Unsubscribed). */
page?: number, export interface GetUsersParams {
pageSize?: number, page?: number
role?: string, page_size?: number
status?: string, role?: string
query?: string, status?: string
) => query?: string
created_before?: string
created_after?: string
country?: string
region?: string
subscription_status?: string
}
function buildGetUsersQuery(params: GetUsersParams): Record<string, string | number> {
const q: Record<string, string | number> = {}
const addString = (key: string, value: string | undefined) => {
const v = value?.trim()
if (!v) return
q[key] = v
}
if (params.page !== undefined) q.page = params.page
if (params.page_size !== undefined) q.page_size = params.page_size
addString("role", params.role)
addString("status", params.status)
addString("query", params.query)
addString("created_before", params.created_before)
addString("created_after", params.created_after)
addString("country", params.country)
addString("region", params.region)
addString("subscription_status", params.subscription_status)
return q
}
export const getUsers = (params: GetUsersParams = {}) =>
http.get<GetUsersResponse>("/users", { http.get<GetUsersResponse>("/users", {
params: { params: buildGetUsersQuery(params),
role,
status,
query,
page,
page_size: pageSize,
},
}); });
export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING"; export type UserStatus = "ACTIVE" | "DEACTIVATED" | "SUSPENDED" | "PENDING";
@ -38,6 +61,9 @@ export const updateUserStatus = (payload: UpdateUserStatusRequest) =>
export const getUserById = (id: number) => export const getUserById = (id: number) =>
http.get<UserProfileResponse>(`/user/single/${id}`); http.get<UserProfileResponse>(`/user/single/${id}`);
export const getUserRecentActivity = (userId: number) =>
http.get<UserRecentActivityResponse>(`/admin/users/${userId}/recent-activity`);
export const getMyProfile = () => export const getMyProfile = () =>
http.get<UserProfileResponse>("/team/me"); http.get<UserProfileResponse>("/team/me");

View File

@ -15,15 +15,15 @@ import { SpeakingPage } from "../pages/content-management/SpeakingPage";
import { AddVideoPage } from "../pages/content-management/AddVideoPage"; import { AddVideoPage } from "../pages/content-management/AddVideoPage";
import { AddPracticePage } from "../pages/content-management/AddPracticePage"; import { AddPracticePage } from "../pages/content-management/AddPracticePage";
import { NewContentPage } from "../pages/content-management/NewContentPage"; import { NewContentPage } from "../pages/content-management/NewContentPage";
import { ReorderContentPage } from "../pages/content-management/ReorderContentPage";
import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage"; import { LearnEnglishPage } from "../pages/content-management/LearnEnglishPage";
import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage"; import { ProgramCoursesPage } from "../pages/content-management/ProgramCoursesPage";
import { CourseDetailPage } from "../pages/content-management/CourseDetailPage"; import { CourseDetailPage } from "../pages/content-management/CourseDetailPage";
import { LessonPracticesPage } from "../pages/content-management/LessonPracticesPage";
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 { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage"; import { CourseModuleDetailPage } from "../pages/content-management/CourseModuleDetailPage";
import { AttachPracticeFlow } from "../pages/content-management/AttachPracticeFlow";
import { AttachProgramPracticeFlow } from "../pages/content-management/AttachProgramPracticeFlow";
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";
import { CourseManagementPage } from "../pages/content-management/CourseManagementPage"; import { CourseManagementPage } from "../pages/content-management/CourseManagementPage";
@ -33,10 +33,12 @@ 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 { EmailTemplatesPage } from "../pages/notifications/EmailTemplatesPage";
import { EmailTemplateDetailPage } from "../pages/notifications/EmailTemplateDetailPage";
import { CreateEmailTemplatePage } from "../pages/notifications/CreateEmailTemplatePage";
import { UserDetailPage } from "../pages/user-management/UserDetailPage"; import { UserDetailPage } from "../pages/user-management/UserDetailPage";
import { UserManagementLayout } from "../pages/user-management/UserManagementLayout"; import { UserManagementLayout } from "../pages/user-management/UserManagementLayout";
import { UsersListPage } from "../pages/user-management/UsersListPage"; import { UsersListPage } from "../pages/user-management/UsersListPage";
import { UserManagementDashboard } from "../pages/user-management/UserManagementDashboard";
import { UserGroupsPage } from "../pages/user-management/UserGroupsPage"; import { UserGroupsPage } from "../pages/user-management/UserGroupsPage";
import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage"; import { DeletionRequestsPage } from "../pages/user-management/DeletionRequestsPage";
import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout"; import { RoleManagementLayout } from "../pages/role-management/RoleManagementLayout";
@ -50,6 +52,7 @@ import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLan
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage"; import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
import { UserLogPage } from "../pages/user-log/UserLogPage"; import { UserLogPage } from "../pages/user-log/UserLogPage";
import { IssuesPage } from "../pages/issues/IssuesPage"; import { IssuesPage } from "../pages/issues/IssuesPage";
import { PaymentsPage } from "../pages/payments/PaymentsPage";
import { ProfilePage } from "../pages/ProfilePage"; import { ProfilePage } from "../pages/ProfilePage";
import { SettingsPage } from "../pages/SettingsPage"; import { SettingsPage } from "../pages/SettingsPage";
import { TeamManagementPage } from "../pages/team/TeamManagementPage"; import { TeamManagementPage } from "../pages/team/TeamManagementPage";
@ -58,6 +61,7 @@ import { TeamMemberDetailPage } from "../pages/team/TeamMemberDetailPage";
import { LoginPage } from "../pages/auth/LoginPage"; import { LoginPage } from "../pages/auth/LoginPage";
import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage"; import { ForgotPasswordPage } from "../pages/auth/ForgotPasswordPage";
import { VerificationPage } from "../pages/auth/VerificationPage"; import { VerificationPage } from "../pages/auth/VerificationPage";
import { AcceptInvitePage } from "../pages/auth/AcceptInvitePage";
import { AboutPage } from "../pages/AboutPage"; import { AboutPage } from "../pages/AboutPage";
import { TermsPage } from "../pages/TermsPage"; import { TermsPage } from "../pages/TermsPage";
import { PrivacyPage } from "../pages/PrivacyPage"; import { PrivacyPage } from "../pages/PrivacyPage";
@ -69,6 +73,7 @@ export function AppRoutes() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/verification" element={<VerificationPage />} /> <Route path="/verification" element={<VerificationPage />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/about" element={<AboutPage />} /> <Route path="/about" element={<AboutPage />} />
<Route path="/terms" element={<TermsPage />} /> <Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} /> <Route path="/privacy" element={<PrivacyPage />} />
@ -77,7 +82,7 @@ export function AppRoutes() {
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} /> <Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserManagementLayout />}> <Route path="/users" element={<UserManagementLayout />}>
<Route index element={<UserManagementDashboard />} /> <Route index element={<Navigate to="list" replace />} />
<Route path="list" element={<UsersListPage />} /> <Route path="list" element={<UsersListPage />} />
<Route path="deletion-requests" element={<DeletionRequestsPage />} /> <Route path="deletion-requests" element={<DeletionRequestsPage />} />
<Route path="groups" element={<UserGroupsPage />} /> <Route path="groups" element={<UserGroupsPage />} />
@ -162,6 +167,7 @@ export function AppRoutes() {
</Route> </Route>
<Route path="/new-content" element={<NewContentPage />} /> <Route path="/new-content" element={<NewContentPage />} />
<Route path="/new-content/reorder" element={<ReorderContentPage />} />
<Route <Route
path="/new-content/courses" path="/new-content/courses"
element={<ProgramTypeSelectionPage />} element={<ProgramTypeSelectionPage />}
@ -170,6 +176,10 @@ export function AppRoutes() {
path="/new-content/question-types" path="/new-content/question-types"
element={<QuestionTypeLibraryPage />} element={<QuestionTypeLibraryPage />}
/> />
<Route
path="/new-content/question-types/:definitionId/edit"
element={<CreateQuestionTypeFlow />}
/>
<Route <Route
path="/new-content/question-types/create" path="/new-content/question-types/create"
element={<CreateQuestionTypeFlow />} element={<CreateQuestionTypeFlow />}
@ -179,12 +189,16 @@ export function AppRoutes() {
element={<ProgramDetailPage />} element={<ProgramDetailPage />}
/> />
<Route <Route
path="/new-content/courses/:programType/attach-practice" path="/new-content/courses/:programType/add-practice"
element={<AttachProgramPracticeFlow />} element={<AddPracticeFlow />}
/> />
<Route <Route
path="/new-content/courses/:programType/:courseId/unit/:unitId/module/:moduleId/attach-practice" path="/new-content/courses/:programType/:courseId/add-practice"
element={<AttachPracticeFlow />} element={<AddPracticeFlow />}
/>
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/add-practice"
element={<AddPracticeFlow />}
/> />
<Route <Route
path="/new-content/courses/:programType/:courseId" path="/new-content/courses/:programType/:courseId"
@ -198,6 +212,10 @@ export function AppRoutes() {
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId" path="/new-content/courses/:programType/:courseId/:unitId/:moduleId"
element={<CourseModuleDetailPage />} element={<CourseModuleDetailPage />}
/> />
<Route
path="/new-content/courses/:programType/:courseId/:unitId/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route <Route
path="/new-content/learn-english" path="/new-content/learn-english"
element={<LearnEnglishPage />} element={<LearnEnglishPage />}
@ -218,16 +236,33 @@ export function AppRoutes() {
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video" path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/add-video"
element={<AddVideoFlow />} element={<AddVideoFlow />}
/> />
<Route
path="/new-content/learn-english/:level/courses/:courseId/modules/:moduleId/lessons/:lessonId/practices"
element={<LessonPracticesPage />}
/>
<Route <Route
path="/new-content/learn-english/:level/courses/add-practice" path="/new-content/learn-english/:level/courses/add-practice"
element={<AddPracticeFlow />} element={<AddPracticeFlow />}
/> />
<Route path="/notifications" element={<NotificationsPage />} /> <Route path="/notifications" element={<NotificationsPage />} />
<Route
path="/notifications/email-templates"
element={<EmailTemplatesPage />}
/>
<Route
path="/notifications/email-templates/new"
element={<CreateEmailTemplatePage />}
/>
<Route
path="/notifications/email-templates/:slug"
element={<EmailTemplateDetailPage />}
/>
<Route <Route
path="/notifications/create" path="/notifications/create"
element={<CreateNotificationPage />} element={<CreateNotificationPage />}
/> />
<Route path="/payments" element={<PaymentsPage />} />
<Route path="/user-log" element={<UserLogPage />} /> <Route path="/user-log" element={<UserLogPage />} />
<Route path="/issues" element={<IssuesPage />} /> <Route path="/issues" element={<IssuesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} /> <Route path="/analytics" element={<AnalyticsPage />} />

View File

@ -0,0 +1,272 @@
import { useEffect, useRef, useState } from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "../../lib/utils"
import { Input } from "../ui/input"
import { Button } from "../ui/button"
import type { DashboardFilters } from "../../types/analytics.types"
const MONTH_LABELS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const
const MIN_SELECTABLE_YEAR = 2000
export function getYearOptions(): number[] {
const currentYear = new Date().getFullYear()
const years: number[] = []
for (let year = currentYear; year >= MIN_SELECTABLE_YEAR; year--) {
years.push(year)
}
return years
}
export function getDashboardFilterLabel(filters: DashboardFilters): string {
if (filters.mode === "year" && filters.year != null) {
return String(filters.year)
}
if (filters.mode === "year_month" && filters.year != null && filters.month != null) {
return `${MONTH_LABELS[filters.month - 1]} ${filters.year}`
}
if (filters.mode === "custom" && filters.from && filters.to) {
const from = new Date(`${filters.from}T00:00:00`)
const to = new Date(`${filters.to}T00:00:00`)
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" }
return `${from.toLocaleDateString("en-US", opts)} ${to.toLocaleDateString("en-US", opts)}`
}
return "All Time"
}
type AnalyticsTimeRangeFilterProps = {
value: DashboardFilters
onChange: (filters: DashboardFilters) => void
className?: string
}
export function AnalyticsTimeRangeFilter({ value, onChange, className }: AnalyticsTimeRangeFilterProps) {
const [open, setOpen] = useState(false)
const [yearOpen, setYearOpen] = useState(true)
const [monthOpen, setMonthOpen] = useState(false)
const [customOpen, setCustomOpen] = useState(false)
const [contextYear, setContextYear] = useState(() => value.year ?? new Date().getFullYear())
const [customFrom, setCustomFrom] = useState(value.from ?? "")
const [customTo, setCustomTo] = useState(value.to ?? "")
const containerRef = useRef<HTMLDivElement>(null)
const years = getYearOptions()
useEffect(() => {
if (value.year != null) {
setContextYear(value.year)
}
}, [value.year])
useEffect(() => {
if (value.mode === "custom") {
setCustomFrom(value.from ?? "")
setCustomTo(value.to ?? "")
}
}, [value.from, value.mode, value.to])
useEffect(() => {
if (!open) return
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handlePointerDown)
return () => document.removeEventListener("mousedown", handlePointerDown)
}, [open])
const selectAllTime = () => {
onChange({ mode: "all_time" })
setOpen(false)
}
const selectYear = (year: number) => {
setContextYear(year)
onChange({ mode: "year", year })
setOpen(false)
}
const selectMonth = (month: number) => {
onChange({ mode: "year_month", year: contextYear, month })
setOpen(false)
}
const applyCustomRange = () => {
if (!customFrom || !customTo) return
onChange({ mode: "custom", from: customFrom, to: customTo })
setOpen(false)
}
return (
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="inline-flex items-center gap-2 rounded-lg border border-grayScale-200 bg-white px-4 py-2 text-sm font-medium text-grayScale-700 shadow-sm transition-colors hover:bg-grayScale-50"
>
Time Range
<ChevronDown className={cn("h-4 w-4 text-grayScale-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="absolute right-0 z-50 mt-2 w-[220px] overflow-hidden rounded-xl border border-grayScale-100 bg-white py-2 shadow-lg">
<button
type="button"
onClick={selectAllTime}
className={cn(
"flex w-full px-4 py-2.5 text-left text-sm transition-colors hover:bg-grayScale-50",
value.mode === "all_time" ? "font-semibold text-grayScale-900" : "text-grayScale-700",
)}
>
All Time
</button>
<div className="border-t border-grayScale-100">
<button
type="button"
onClick={() => setYearOpen((prev) => !prev)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
>
Year
<ChevronDown
className={cn("h-4 w-4 text-grayScale-400 transition-transform", yearOpen && "rotate-180")}
/>
</button>
{yearOpen && (
<div className="max-h-[220px] overflow-y-auto pb-1">
{years.map((year) => (
<button
key={year}
type="button"
onClick={() => selectYear(year)}
className={cn(
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
value.mode === "year" && value.year === year
? "font-semibold text-brand-600"
: "text-grayScale-600",
)}
>
{year}
</button>
))}
</div>
)}
</div>
<div className="border-t border-grayScale-100">
<button
type="button"
onClick={() => setMonthOpen((prev) => !prev)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
>
Month
<ChevronDown
className={cn("h-4 w-4 text-grayScale-400 transition-transform", monthOpen && "rotate-180")}
/>
</button>
{monthOpen && (
<div className="max-h-[260px] overflow-y-auto pb-1">
<div className="flex max-h-[88px] flex-wrap gap-1 overflow-y-auto px-4 pb-2">
{years.map((year) => (
<button
key={year}
type="button"
onClick={() => setContextYear(year)}
className={cn(
"rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors",
contextYear === year
? "bg-brand-100 text-brand-700"
: "text-grayScale-500 hover:bg-grayScale-100",
)}
>
{year}
</button>
))}
</div>
{MONTH_LABELS.map((label, index) => {
const month = index + 1
const isSelected =
value.mode === "year_month" && value.year === contextYear && value.month === month
return (
<button
key={label}
type="button"
onClick={() => selectMonth(month)}
className={cn(
"flex w-full px-6 py-2 text-left text-sm transition-colors hover:bg-grayScale-50",
isSelected ? "font-semibold text-brand-600" : "text-grayScale-600",
)}
>
{label}
</button>
)
})}
</div>
)}
</div>
<div className="border-t border-grayScale-100">
<button
type="button"
onClick={() => setCustomOpen((prev) => !prev)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-medium text-grayScale-800 hover:bg-grayScale-50"
>
Date Range
<ChevronDown
className={cn("h-4 w-4 text-grayScale-400 transition-transform", customOpen && "rotate-180")}
/>
</button>
{customOpen && (
<div className="space-y-2 px-4 pb-3">
<div className="space-y-1">
<label className="text-[11px] font-medium text-grayScale-500">From</label>
<Input
type="date"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-grayScale-500">To</label>
<Input
type="date"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
className="h-8 text-xs"
/>
</div>
<Button
type="button"
size="sm"
className="h-8 w-full text-xs"
disabled={!customFrom || !customTo}
onClick={applyCustomRange}
>
Apply
</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,241 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import {
addMatchingInputRow,
addMatchingPair,
defaultMatchingAnswerFromInputs,
MATCHING_MIN_ITEMS,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
removeMatchingInputRow,
removeMatchingPair,
serializeMatchingAnswerSlotValue,
serializeMatchingInputsSlotValue,
type MatchingAnswerSlotValue,
type MatchingInputsSlotValue,
} from "../../lib/matchingSlotValue"
export function DynamicMatchingInputsSlot({
value,
onChange,
disabled,
slotLabel,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
}) {
const parsed = parseMatchingInputsSlotValue(value)
const updateValue = (next: MatchingInputsSlotValue) => {
onChange(serializeMatchingInputsSlotValue(next))
}
const updateSide = (
side: "left" | "right",
index: number,
text: string,
) => {
const next = {
left: [...parsed.left],
right: [...parsed.right],
}
next[side][index] = { ...next[side][index], text }
updateValue(next)
}
const rowCount = Math.max(parsed.left.length, parsed.right.length)
const canRemove = rowCount > MATCHING_MIN_ITEMS
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addMatchingInputRow(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
</div>
<div className="space-y-3">
{Array.from({ length: rowCount }).map((_, index) => (
<div
key={`matching-row-${index}`}
className="grid gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3 md:grid-cols-[1fr_1fr_auto]"
>
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Left {parsed.left[index]?.id ?? `l${index + 1}`}
</p>
<Input
value={parsed.left[index]?.text ?? ""}
onChange={(e) => updateSide("left", index, e.target.value)}
placeholder={`Left item ${index + 1}`}
className="rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Right {parsed.right[index]?.id ?? `r${index + 1}`}
</p>
<Input
value={parsed.right[index]?.text ?? ""}
onChange={(e) => updateSide("right", index, e.target.value)}
placeholder={`Right item ${index + 1}`}
className="rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemove}
className="h-9 w-9 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove row ${index + 1}`}
onClick={() => updateValue(removeMatchingInputRow(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
<p className="text-[11px] text-grayScale-500">
Minimum {MATCHING_MIN_ITEMS} rows on each side. Whitespace in text is preserved.
</p>
</div>
)
}
export function DynamicMatchingAnswerSlot({
value,
onChange,
disabled,
slotLabel,
matchingInputs,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
matchingInputs: MatchingInputsSlotValue | null
}) {
const parsed = parseMatchingAnswerSlotValue(value, matchingInputs)
const updateValue = (next: MatchingAnswerSlotValue) => {
onChange(serializeMatchingAnswerSlotValue(next))
}
const leftOptions = matchingInputs?.left ?? []
const rightOptions = matchingInputs?.right ?? []
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<div className="flex flex-wrap gap-2">
{matchingInputs ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 rounded-lg"
onClick={() =>
updateValue(defaultMatchingAnswerFromInputs(matchingInputs))
}
>
Reset from inputs
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addMatchingPair(parsed, matchingInputs))}
>
<Plus className="h-3.5 w-3.5" />
Add pair
</Button>
</div>
</div>
{!matchingInputs ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Fill in matching inputs first so answer pairs can reference left and right ids.
</p>
) : null}
<div className="space-y-2">
{parsed.pairs.map((pair, index) => (
<div
key={`pair-${index}-${pair.left_id}-${pair.right_id}`}
className="flex flex-wrap items-center gap-2"
>
<select
value={pair.left_id}
disabled={disabled || leftOptions.length === 0}
onChange={(e) => {
const pairs = [...parsed.pairs]
pairs[index] = { ...pairs[index], left_id: e.target.value }
updateValue({ pairs })
}}
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
{leftOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
</option>
))}
</select>
<span className="text-sm text-grayScale-400"></span>
<select
value={pair.right_id}
disabled={disabled || rightOptions.length === 0}
onChange={(e) => {
const pairs = [...parsed.pairs]
pairs[index] = { ...pairs[index], right_id: e.target.value }
updateValue({ pairs })
}}
className="h-10 min-w-[120px] rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
{rightOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text.slice(0, 40)}` : ""}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || parsed.pairs.length <= 1}
className="h-8 w-8 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove pair ${index + 1}`}
onClick={() => updateValue(removeMatchingPair(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,275 @@
import { Plus, Trash2 } from "lucide-react"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import {
addBlankSegment,
addTextSegment,
addWordBankItem,
defaultSelectMissingWordsResponseFromStimulus,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
removeSegment,
removeWordBankItem,
SELECT_MISSING_WORDS_MIN_BANK,
serializeSelectMissingWordsResponseSlotValue,
serializeSelectMissingWordsStimulusSlotValue,
setAllowReuse,
syncResponseBlanksWithStimulus,
updateTextSegment,
updateWordBankText,
type SelectMissingWordsStimulusValue,
} from "../../lib/selectMissingWordsSlotValue"
export function DynamicSelectMissingWordsStimulusSlot({
value,
onChange,
disabled,
slotLabel,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
}) {
const parsed = parseSelectMissingWordsStimulusSlotValue(value)
const updateValue = (next: SelectMissingWordsStimulusValue) => {
onChange(serializeSelectMissingWordsStimulusSlotValue(next))
}
const canRemoveWord = parsed.word_bank.length > SELECT_MISSING_WORDS_MIN_BANK
const canRemoveSegment = parsed.segments.length > 1
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addTextSegment(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add text
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addBlankSegment(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add blank
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Passage segments
</p>
{parsed.segments.map((segment, index) => (
<div
key={`segment-${index}-${segment.type === "blank" ? segment.id : "text"}`}
className="flex flex-wrap items-start gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
>
{segment.type === "text" ? (
<div className="min-w-0 flex-1 space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Text
</p>
<Textarea
rows={2}
value={segment.value}
onChange={(e) =>
updateValue(updateTextSegment(parsed, index, e.target.value))
}
placeholder="Text before or after a blank"
className="min-h-[56px] resize-y rounded-lg border-grayScale-200 bg-white font-mono text-sm"
disabled={disabled}
/>
</div>
) : (
<div className="flex min-h-[56px] flex-1 items-center rounded-lg border border-dashed border-brand-200 bg-brand-50/40 px-3">
<span className="text-sm font-medium text-brand-700">
Blank {segment.id}
</span>
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemoveSegment}
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove segment ${index + 1}`}
onClick={() => updateValue(removeSegment(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-grayScale-400">
Word bank
</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 gap-1.5 rounded-lg border-brand-200 text-brand-600 hover:bg-brand-50"
onClick={() => updateValue(addWordBankItem(parsed))}
>
<Plus className="h-3.5 w-3.5" />
Add word
</Button>
</div>
{parsed.word_bank.map((item, index) => (
<div
key={`word-${item.id}`}
className="flex flex-wrap items-center gap-2"
>
<span className="w-10 text-xs font-medium text-grayScale-500">{item.id}</span>
<Input
value={item.text}
onChange={(e) =>
updateValue(updateWordBankText(parsed, index, e.target.value))
}
placeholder={`Word ${index + 1}`}
className="h-10 flex-1 rounded-lg border-grayScale-200 bg-white"
disabled={disabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !canRemoveWord}
className="h-9 w-9 shrink-0 text-grayScale-400 hover:text-red-600 disabled:opacity-40"
aria-label={`Remove word ${item.id}`}
onClick={() => updateValue(removeWordBankItem(parsed, index))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<label className="flex items-center gap-2 text-sm text-grayScale-700">
<input
type="checkbox"
checked={parsed.allow_reuse}
disabled={disabled}
onChange={(e) => updateValue(setAllowReuse(parsed, e.target.checked))}
className="h-4 w-4 rounded border-grayScale-300"
/>
Allow word reuse across blanks
</label>
<p className="text-[11px] text-grayScale-500">
Minimum {SELECT_MISSING_WORDS_MIN_BANK} words in the bank and at least one blank.
Whitespace in text is preserved.
</p>
</div>
)
}
export function DynamicSelectMissingWordsAnswerSlot({
value,
onChange,
disabled,
slotLabel,
stimulus,
}: {
value: string
onChange: (next: string) => void
disabled: boolean
slotLabel: string
stimulus: SelectMissingWordsStimulusValue | null
}) {
const parsed = parseSelectMissingWordsResponseSlotValue(value, stimulus)
const synced = stimulus
? syncResponseBlanksWithStimulus(parsed, stimulus)
: parsed
const updateValue = (next: ReturnType<typeof parseSelectMissingWordsResponseSlotValue>) => {
onChange(serializeSelectMissingWordsResponseSlotValue(next))
}
const wordOptions =
stimulus?.word_bank.filter((item) => item.text.length > 0) ?? []
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="text-sm font-medium text-grayScale-700">{slotLabel}</label>
{stimulus ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="h-8 rounded-lg"
onClick={() =>
updateValue(defaultSelectMissingWordsResponseFromStimulus(stimulus))
}
>
Reset from blanks
</Button>
) : null}
</div>
{!stimulus ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Fill in the cloze passage and word bank first so answers can reference blank and word ids.
</p>
) : null}
<div className="space-y-2">
{synced.blanks.map((blank, index) => (
<div
key={`blank-answer-${blank.blank_id}`}
className="flex flex-wrap items-center gap-2 rounded-lg border border-grayScale-200 bg-grayScale-50/50 p-3"
>
<span className="w-10 text-xs font-medium text-grayScale-500">
{blank.blank_id}
</span>
<select
value={blank.word_id}
disabled={disabled || wordOptions.length === 0}
onChange={(e) => {
const selected = wordOptions.find((item) => item.id === e.target.value)
const blanks = [...synced.blanks]
blanks[index] = {
blank_id: blank.blank_id,
word_id: e.target.value,
text: selected?.text ?? "",
}
updateValue({ blanks })
}}
className="h-10 min-w-[160px] flex-1 rounded-lg border border-grayScale-200 bg-white px-3 text-sm"
>
<option value="">Select word</option>
{wordOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.id}
{item.text ? `: ${item.text}` : ""}
</option>
))}
</select>
</div>
))}
</div>
</div>
)
}

View File

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

View File

@ -9,15 +9,23 @@ import { Check, Image as ImageIcon, Mic, Plus, Upload, X } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { resolveMediaPreviewUrl } from "../../lib/practiceMedia" import { resolveMediaPreviewUrl } from "../../lib/practiceMedia"
import { uploadAudioFile, uploadImageFile } from "../../api/files.api" import { uploadAudioFile, uploadImageFile } from "../../api/files.api"
import {
getQuestionTypeDefinitionById,
getQuestionTypeDefinitions,
questionTypeDefinitionListLabel,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { Textarea } from "../ui/textarea"
import { Select } from "../ui/select" import { Select } from "../ui/select"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { SpinnerIcon } from "../ui/spinner-icon" import { SpinnerIcon } from "../ui/spinner-icon"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { ResolvedAudio } from "../media/ResolvedAudio" import { ResolvedAudio } from "../media/ResolvedAudio"
import { ResolvedImage } from "../media/ResolvedImage" import { ResolvedImage } from "../media/ResolvedImage"
import { DynamicSchemaSlotField } from "./DynamicSchemaSlotField"
export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" export type PracticeQuestionEditorType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD" export type PracticeQuestionEditorDifficulty = "EASY" | "MEDIUM" | "HARD"
export interface PracticeQuestionOptionDraft { export interface PracticeQuestionOptionDraft {
@ -32,6 +40,13 @@ const ALLOWED_AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "we
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024 const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"]) const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "webp", "gif"])
export interface PracticeQuestionDynamicRow {
id: string
kind: string
label?: string
required?: boolean
}
export interface PracticeQuestionEditorValue { export interface PracticeQuestionEditorValue {
questionText: string questionText: string
questionType: PracticeQuestionEditorType questionType: PracticeQuestionEditorType
@ -46,6 +61,12 @@ export interface PracticeQuestionEditorValue {
shortAnswer: string shortAnswer: string
/** Stored URL or object key; same semantics as Speaking practice editor */ /** Stored URL or object key; same semantics as Speaking practice editor */
imageUrl: string imageUrl: string
/** When `questionType` is DYNAMIC — definition used to shape `dynamic_payload` */
questionTypeDefinitionId: number | null
dynamicStimulusRows: PracticeQuestionDynamicRow[]
dynamicResponseRows: PracticeQuestionDynamicRow[]
/** Keys `stimulus:${elementId}` and `response:${elementId}` (ids from the type definition schema) */
dynamicFieldValues: Record<string, string>
} }
export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue { export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue {
@ -67,6 +88,10 @@ export function createEmptyPracticeQuestionDraft(): PracticeQuestionEditorValue
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
shortAnswer: "", shortAnswer: "",
imageUrl: "", imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
} }
} }
@ -84,6 +109,9 @@ function defaultOptionsForType(
previousType: PracticeQuestionEditorType, previousType: PracticeQuestionEditorType,
current: PracticeQuestionOptionDraft[], current: PracticeQuestionOptionDraft[],
): PracticeQuestionOptionDraft[] { ): PracticeQuestionOptionDraft[] {
if (type === "DYNAMIC") {
return current
}
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {
if (previousType === "TRUE_FALSE" && current.length >= 2) { if (previousType === "TRUE_FALSE" && current.length >= 2) {
return current.map((o, i) => ({ return current.map((o, i) => ({
@ -146,6 +174,18 @@ export function PracticeQuestionEditorFields({
const setType = (questionType: PracticeQuestionEditorType) => { const setType = (questionType: PracticeQuestionEditorType) => {
const options = defaultOptionsForType(questionType, value.questionType, value.options) const options = defaultOptionsForType(questionType, value.questionType, value.options)
if (questionType === "DYNAMIC" || value.questionType === "DYNAMIC") {
onChange({
...value,
questionType,
options,
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
})
return
}
onChange({ ...value, questionType, options }) onChange({ ...value, questionType, options })
} }
@ -586,11 +626,97 @@ export function PracticeQuestionEditorFields({
const controlsDisabled = mediaBusy const controlsDisabled = mediaBusy
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
const [definitionsLoading, setDefinitionsLoading] = useState(false)
const [definitionDetailLoading, setDefinitionDetailLoading] = useState(false)
useEffect(() => {
if (value.questionType !== "DYNAMIC") return
let cancelled = false
setDefinitionsLoading(true)
;(async () => {
try {
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
} finally {
if (!cancelled) setDefinitionsLoading(false)
}
})()
return () => {
cancelled = true
}
}, [value.questionType])
const handleDynamicDefinitionChange = async (rawId: string) => {
if (!rawId) {
onChange({
...value,
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
})
return
}
const id = Number(rawId)
if (!Number.isFinite(id) || id <= 0) return
setDefinitionDetailLoading(true)
try {
const def = await getQuestionTypeDefinitionById(id)
if (!def) {
toast.error("Definition not found")
return
}
const fieldValues: Record<string, string> = { ...value.dynamicFieldValues }
const dynamicStimulusRows: PracticeQuestionDynamicRow[] = def.stimulus_schema.map((r) => ({
id: r.id,
kind: r.kind,
label: r.label,
required: r.required,
}))
const dynamicResponseRows: PracticeQuestionDynamicRow[] = def.response_schema.map((r) => ({
id: r.id,
kind: r.kind,
label: r.label,
required: r.required,
}))
for (const r of dynamicStimulusRows) {
const k = `stimulus:${r.id}`
if (fieldValues[k] === undefined) fieldValues[k] = ""
}
for (const r of dynamicResponseRows) {
const k = `response:${r.id}`
if (fieldValues[k] === undefined) fieldValues[k] = ""
}
onChange({
...value,
questionTypeDefinitionId: id,
dynamicStimulusRows,
dynamicResponseRows,
dynamicFieldValues: fieldValues,
})
} catch (e) {
console.error(e)
toast.error("Failed to load definition details")
} finally {
setDefinitionDetailLoading(false)
}
}
const setDynamicField = (key: string, next: string) => {
onChange({
...value,
dynamicFieldValues: { ...value.dynamicFieldValues, [key]: next },
})
}
return ( return (
<> <>
<div className="mt-5 space-y-5"> <div className="mt-3 space-y-3">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Question Text</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Question Text</label>
<textarea <textarea
value={value.questionText} value={value.questionText}
onChange={(e) => patch({ questionText: e.target.value })} onChange={(e) => patch({ questionText: e.target.value })}
@ -607,35 +733,40 @@ export function PracticeQuestionEditorFields({
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 lg:gap-3">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Type</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Type</label>
<Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)}> <Select value={value.questionType} onChange={(e) => setType(e.target.value as PracticeQuestionEditorType)} className="h-9 text-sm">
<option value="MCQ">Multiple Choice</option> <option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option> <option value="TRUE_FALSE">True/False</option>
<option value="SHORT">Short Answer</option> <option value="SHORT">Short Answer</option>
<option value="AUDIO">Audio</option> <option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Difficulty</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Difficulty</label>
<Select <Select
value={value.difficultyLevel} value={value.difficultyLevel}
onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })} onChange={(e) => patch({ difficultyLevel: e.target.value as PracticeQuestionEditorDifficulty })}
className="h-9 text-sm"
> >
<option value="EASY">Easy</option> <option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option> <option value="MEDIUM">Medium</option>
<option value="HARD">Hard</option> <option value="HARD">Hard</option>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Points</label> <label className="text-[11px] font-medium uppercase tracking-wide text-grayScale-500">Points</label>
<Input <Input
type="number" type="number"
value={value.points} value={value.points}
onChange={(e) => patch({ points: Number(e.target.value) || 1 })} onChange={(e) => patch({ points: Number(e.target.value) || 1 })}
min={1} min={1}
className={cn(showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined)} className={cn(
"h-9 text-sm",
showFieldErrors && fieldErrors.points ? "border-red-300 ring-1 ring-red-200" : undefined,
)}
aria-invalid={Boolean(showFieldErrors && fieldErrors.points)} aria-invalid={Boolean(showFieldErrors && fieldErrors.points)}
/> />
{showFieldErrors && fieldErrors.points ? ( {showFieldErrors && fieldErrors.points ? (
@ -644,8 +775,82 @@ export function PracticeQuestionEditorFields({
</div> </div>
</div> </div>
{value.questionType === "DYNAMIC" && (
<div className="space-y-2 rounded-lg border border-violet-200 bg-violet-50/50 p-2.5 sm:p-3">
<p className="text-xs leading-snug text-grayScale-600 sm:text-sm">
Image, audio, and PDF slots support upload or a URL. Table slots use the visual builder. Other
fields accept text or structured values where noted.
</p>
<div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
value={value.questionTypeDefinitionId != null ? String(value.questionTypeDefinitionId) : ""}
onChange={(e) => void handleDynamicDefinitionChange(e.target.value)}
disabled={definitionsLoading || definitionDetailLoading}
className="h-9 text-sm"
>
<option value="">{definitionsLoading ? "Loading definitions…" : "Select definition…"}</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
{questionTypeDefinitionListLabel(d)}
</option>
))}
</Select>
</div>
{definitionDetailLoading ? (
<p className="text-sm font-medium text-grayScale-500">Loading schema</p>
) : null}
{value.dynamicStimulusRows.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Stimulus</p>
{value.dynamicStimulusRows.map((row) => (
<div
key={`stimulus-${row.id}`}
className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
>
<DynamicSchemaSlotField
row={row}
side="stimulus"
value={value.dynamicFieldValues[`stimulus:${row.id}`] ?? ""}
onChange={(next) => setDynamicField(`stimulus:${row.id}`, next)}
disabled={controlsDisabled}
allFieldValues={value.dynamicFieldValues}
stimulusSchema={value.dynamicStimulusRows}
responseSchema={value.dynamicResponseRows}
/>
</div>
))}
</div>
) : null}
{value.dynamicResponseRows.length > 0 ? (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wide text-violet-800">Response</p>
{value.dynamicResponseRows.map((row) => (
<div
key={`response-${row.id}`}
className="rounded-lg border border-grayScale-200 bg-white p-2.5 shadow-sm sm:p-3"
>
<DynamicSchemaSlotField
row={row}
side="response"
value={value.dynamicFieldValues[`response:${row.id}`] ?? ""}
onChange={(next) => setDynamicField(`response:${row.id}`, next)}
disabled={controlsDisabled}
allFieldValues={value.dynamicFieldValues}
stimulusSchema={value.dynamicStimulusRows}
responseSchema={value.dynamicResponseRows}
/>
</div>
))}
</div>
) : null}
</div>
)}
{value.questionType === "MCQ" && ( {value.questionType === "MCQ" && (
<div className="space-y-3 rounded-lg bg-grayScale-50/50 p-4"> <div className="space-y-2 rounded-lg bg-grayScale-50/50 p-3">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Options</label>
<div className="space-y-2.5"> <div className="space-y-2.5">
{value.options.map((option, optIdx) => ( {value.options.map((option, optIdx) => (
@ -758,7 +963,7 @@ export function PracticeQuestionEditorFields({
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6"> <div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Tips (Optional)</label>
<Input <Input
@ -777,6 +982,8 @@ export function PracticeQuestionEditorFields({
</div> </div>
</div> </div>
{value.questionType !== "DYNAMIC" ? (
<>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label> <label className="text-xs font-medium uppercase tracking-wider text-grayScale-500">Voice Prompt (Optional)</label>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -908,6 +1115,8 @@ export function PracticeQuestionEditorFields({
) : null} ) : null}
</div> </div>
</div> </div>
</>
) : null}
</div> </div>
{recordingModal ? ( {recordingModal ? (

View File

@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react"
import { Bar, CartesianGrid, Cell, ComposedChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { getDashboard } from "../../api/analytics.api"
import { getYearOptions } from "../analytics/AnalyticsTimeRangeFilter"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { Select } from "../ui/select"
import { aggregateRevenueByMonth, formatRevenueAxisTick } from "../../lib/analytics"
import type { DateRevenue } from "../../types/analytics.types"
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"
const TRACK_COLOR = "#E8E8E8"
const BAR_COLOR = "#9E2891"
export function RevenueTrendCard() {
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(currentYear)
const [totalRevenue, setTotalRevenue] = useState(0)
const [dailyRevenue, setDailyRevenue] = useState<DateRevenue[]>([])
const [loading, setLoading] = useState(true)
const years = useMemo(() => getYearOptions(), [])
useEffect(() => {
let cancelled = false
const fetchRevenueTrend = async () => {
setLoading(true)
try {
const res = await getDashboard({ mode: "year", year })
if (cancelled) return
setTotalRevenue(res.data.payments.total_revenue)
setDailyRevenue(res.data.payments.revenue_last_30_days)
} catch {
if (!cancelled) {
setTotalRevenue(0)
setDailyRevenue([])
}
} finally {
if (!cancelled) setLoading(false)
}
}
fetchRevenueTrend()
return () => {
cancelled = true
}
}, [year])
const chartData = useMemo(() => {
const monthly = aggregateRevenueByMonth(dailyRevenue, year)
const peak = Math.max(...monthly.map((point) => point.revenue), 1)
const trackMax = peak * 1.15
return monthly.map((point) => ({
month: point.month,
revenue: point.revenue,
track: trackMax,
}))
}, [dailyRevenue, year])
return (
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Revenue Trend</CardTitle>
<div className="mt-2 text-2xl font-semibold tracking-tight">
ETB {totalRevenue.toLocaleString()}
</div>
<div className="text-xs font-medium text-grayScale-500">Monthly · {year} (ETB)</div>
</div>
<Select
value={String(year)}
onChange={(e) => setYear(Number(e.target.value))}
className="h-9 w-[96px] shrink-0 rounded-lg py-1 text-sm font-medium"
aria-label="Revenue trend year"
>
{years.map((optionYear) => (
<option key={optionYear} value={optionYear}>
{optionYear}
</option>
))}
</Select>
</div>
</CardHeader>
<CardContent className="h-[240px] p-6 pt-2">
{loading ? (
<div className="flex h-full items-center justify-center">
<img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ left: 4, right: 8, top: 8, bottom: 0 }} barGap={-28}>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="month" tickLine={false} axisLine={false} fontSize={12} />
<YAxis
tickLine={false}
axisLine={false}
fontSize={12}
width={44}
tickFormatter={formatRevenueAxisTick}
/>
<Tooltip
formatter={(value, name) => {
if (name !== "revenue") return null
return [`ETB ${Number(value).toLocaleString()}`, "Revenue"]
}}
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}}
/>
<Bar dataKey="track" barSize={28} radius={[8, 8, 0, 0]} isAnimationActive={false}>
{chartData.map((entry) => (
<Cell key={`track-${entry.month}`} fill={TRACK_COLOR} />
))}
</Bar>
<Bar dataKey="revenue" barSize={28} radius={[8, 8, 0, 0]}>
{chartData.map((entry) => (
<Cell key={`revenue-${entry.month}`} fill={BAR_COLOR} />
))}
</Bar>
</ComposedChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)
}

View File

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

View File

@ -6,12 +6,11 @@ import {
ChevronRight, ChevronRight,
CircleAlert, CircleAlert,
ClipboardList, ClipboardList,
CreditCard,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
Shield,
UserCircle2, UserCircle2,
Users, Users,
Users2,
Settings, Settings,
X, X,
} from "lucide-react"; } from "lucide-react";
@ -20,27 +19,86 @@ import { NavLink } from "react-router-dom";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { BrandLogo } from "../brand/BrandLogo"; import { BrandLogo } from "../brand/BrandLogo";
import { getUnreadCount } from "../../api/notifications.api"; import { getUnreadCount } from "../../api/notifications.api";
import { SidebarNavGroup } from "./SidebarNavGroup";
type NavItem = { type NavLinkItem = {
kind: "link";
label: string; label: string;
to: string; to: string;
icon: ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
}; };
const navItems: NavItem[] = [ type NavGroupItem = {
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard }, kind: "group";
{ label: "User Management", to: "/users", icon: Users }, label: string;
{ label: "Role Management", to: "/roles", icon: Shield }, basePath: string;
{ label: "Content Management", to: "/content", icon: BookOpen }, activePaths?: string[];
{ label: "New Content", to: "/new-content", icon: BookOpen }, icon: ComponentType<{ className?: string }>;
children: { label: string; to: string; end?: boolean }[];
};
{ label: "Notifications", to: "/notifications", icon: Bell }, type NavSectionItem = {
{ label: "User Log", to: "/user-log", icon: ClipboardList }, kind: "section";
{ label: "Issue Reports", to: "/issues", icon: CircleAlert }, label: string;
{ label: "Analytics", to: "/analytics", icon: BarChart3 }, };
{ label: "Team Management", to: "/team", icon: Users2 },
{ label: "Profile", to: "/profile", icon: UserCircle2 }, type NavEntry = NavLinkItem | NavGroupItem | NavSectionItem;
{ label: "Settings", to: "/settings", icon: Settings },
const navEntries: NavEntry[] = [
{ kind: "section", label: "Overview" },
{ kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
{ kind: "section", label: "People" },
{
kind: "group",
label: "Users & access",
basePath: "/users",
activePaths: ["/users", "/roles", "/team"],
icon: Users,
children: [
{ label: "All users", to: "/users/list" },
{ label: "Roles", to: "/roles" },
{ label: "Team members", to: "/team" },
],
},
{ kind: "section", label: "Learning content" },
{
kind: "group",
label: "Content",
basePath: "/content",
activePaths: ["/content", "/new-content"],
icon: BookOpen,
children: [
{ label: "Manage practices", to: "/content", end: true },
{ label: "New content", to: "/new-content", end: true },
{ label: "Reorder structure", to: "/new-content/reorder" },
{ label: "Question types", to: "/new-content/question-types" },
],
},
{ kind: "section", label: "Communications" },
{
kind: "group",
label: "Notifications",
basePath: "/notifications",
icon: Bell,
children: [
{ label: "Inbox", to: "/notifications", end: true },
{ label: "Email templates", to: "/notifications/email-templates" },
{ label: "Send notification", to: "/notifications/create" },
],
},
{ kind: "section", label: "Operations" },
{ kind: "link", label: "Payments", to: "/payments", icon: CreditCard },
{ kind: "link", label: "User activity log", to: "/user-log", icon: ClipboardList },
{ kind: "link", label: "Issue reports", to: "/issues", icon: CircleAlert },
{ kind: "section", label: "Account" },
{ kind: "link", label: "Profile", to: "/profile", icon: UserCircle2 },
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
]; ];
type SidebarProps = { type SidebarProps = {
@ -75,9 +133,18 @@ export function Sidebar({
window.removeEventListener("notifications-updated", fetchUnread); window.removeEventListener("notifications-updated", fetchUnread);
}, []); }, []);
const unreadBadge = unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
);
const collapsedUnreadDot = unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
);
return ( return (
<> <>
{/* Mobile overlay */}
<div <div
className={cn( className={cn(
"fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden", "fixed inset-0 z-40 bg-black/50 transition-opacity lg:hidden",
@ -87,7 +154,6 @@ export function Sidebar({
aria-hidden="true" aria-hidden="true"
/> />
{/* Sidebar panel */}
<aside <aside
className={cn( className={cn(
"group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300", "group fixed left-0 top-0 z-50 flex h-screen flex-col border-r bg-grayScale-50 py-5 transition-all duration-300",
@ -134,13 +200,59 @@ export function Sidebar({
</button> </button>
</div> </div>
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto"> <nav className="mt-6 flex-1 space-y-0.5 overflow-y-auto">
{navItems.map((item) => { {navEntries.map((entry, index) => {
const Icon = item.icon; if (entry.kind === "section") {
if (isCollapsed) {
return index > 0 ? (
<div
key={`section-gap-${entry.label}`}
className="mx-auto my-2 h-px w-6 bg-grayScale-200"
aria-hidden
/>
) : null;
}
return (
<p
key={`section-${entry.label}`}
className={cn(
"mb-1 px-3 pt-3 text-[10px] font-bold uppercase tracking-wider text-grayScale-400",
index === 0 && "pt-0",
)}
>
{entry.label}
</p>
);
}
if (entry.kind === "group") {
const isNotifications = entry.basePath === "/notifications";
return (
<SidebarNavGroup
key={entry.basePath}
label={entry.label}
icon={entry.icon}
basePath={entry.basePath}
activePaths={entry.activePaths}
children={entry.children}
isCollapsed={isCollapsed}
onNavigate={onClose}
trailing={
isNotifications
? !isCollapsed
? unreadBadge
: collapsedUnreadDot
: undefined
}
/>
);
}
const Icon = entry.icon;
return ( return (
<NavLink <NavLink
key={item.to} key={entry.to}
to={item.to} to={entry.to}
onClick={onClose} onClick={onClose}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
@ -151,41 +263,22 @@ export function Sidebar({
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100", "bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
) )
} }
title={isCollapsed ? item.label : undefined} title={isCollapsed ? entry.label : undefined}
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
<span <span
className={cn( className={cn(
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600", "grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isActive && "bg-brand-500/90 text-white", isActive && "bg-brand-500/90 text-white",
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{isCollapsed &&
item.to === "/notifications" &&
unreadCount > 0 && (
<span className="absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-destructive" />
)}
</span> </span>
{!isCollapsed && ( {!isCollapsed && (
<span className="truncate">{item.label}</span> <span className="truncate">{entry.label}</span>
)} )}
{!isCollapsed && {!isCollapsed && isActive ? (
item.to === "/notifications" &&
unreadCount > 0 && (
<span className="ml-auto flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
{!isCollapsed &&
item.to !== "/notifications" &&
isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : !isCollapsed &&
item.to === "/notifications" &&
unreadCount === 0 &&
isActive ? (
<span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" /> <span className="ml-auto h-6 w-1 rounded-full bg-brand-500/80" />
) : null} ) : null}
</> </>

View File

@ -0,0 +1,139 @@
import { ChevronDown } from "lucide-react";
import { type ComponentType, type ReactNode, useEffect, useId, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import { cn } from "../../lib/utils";
export type SidebarNavChild = {
label: string;
to: string;
end?: boolean;
};
type SidebarNavGroupProps = {
label: string;
icon: ComponentType<{ className?: string }>;
basePath: string;
/** When set, any matching prefix marks the group active (e.g. `/content` and `/new-content`). */
activePaths?: string[];
children: SidebarNavChild[];
isCollapsed: boolean;
onNavigate?: () => void;
trailing?: ReactNode;
};
export function SidebarNavGroup({
label,
icon: Icon,
basePath,
activePaths,
children,
isCollapsed,
onNavigate,
trailing,
}: SidebarNavGroupProps) {
const location = useLocation();
const panelId = useId();
const paths = activePaths?.length ? activePaths : [basePath];
const isSectionActive = paths.some((path) => location.pathname.startsWith(path));
const [expanded, setExpanded] = useState(isSectionActive);
useEffect(() => {
if (isSectionActive) {
setExpanded(true);
}
}, [isSectionActive]);
if (isCollapsed) {
return (
<NavLink
to={children[0]?.to ?? basePath}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"group flex items-center justify-center rounded-lg px-2 py-2.5 text-sm font-medium text-grayScale-600 transition",
"hover:bg-grayScale-100 hover:text-brand-600",
isActive &&
"bg-brand-100/40 text-brand-600 shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand-100",
)
}
title={label}
>
{({ isActive }) => (
<span
className={cn(
"relative grid h-8 w-8 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isActive && "bg-brand-500/90 text-white",
)}
>
<Icon className="h-4 w-4" />
{trailing}
</span>
)}
</NavLink>
);
}
return (
<div className="space-y-0.5">
<button
type="button"
aria-expanded={expanded}
aria-controls={panelId}
onClick={() => setExpanded((open) => !open)}
className={cn(
"group flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium text-grayScale-600 transition",
"hover:bg-grayScale-100 hover:text-brand-600",
isSectionActive && "text-brand-600",
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-grayScale-100 text-grayScale-500 transition group-hover:bg-brand-100 group-hover:text-brand-600",
isSectionActive && "bg-brand-500/90 text-white",
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1 truncate">{label}</span>
{trailing}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-grayScale-400 transition-transform duration-300 ease-in-out",
expanded && "rotate-180",
)}
/>
</button>
<div
id={panelId}
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-in-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="ml-4 space-y-0.5 border-l border-grayScale-200 pl-2 pt-0.5 pb-0.5">
{children.map((child) => (
<NavLink
key={child.to}
to={child.to}
end={child.end ?? false}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"block rounded-lg px-3 py-2 text-sm font-medium transition",
isActive
? "bg-brand-100/40 text-brand-600"
: "text-grayScale-500 hover:bg-grayScale-100 hover:text-brand-600",
)
}
>
{child.label}
</NavLink>
))}
</div>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,228 @@
/**
* Static options for GET /users filters (`country`, `region`).
* Country: common English short names (ISO-style), sorted AZ.
* Region: Ethiopia federal regions & chartered cities (typical `users.region` values).
*/
const COUNTRY_NAMES_RAW = [
"Afghanistan",
"Albania",
"Algeria",
"Andorra",
"Angola",
"Antigua and Barbuda",
"Argentina",
"Armenia",
"Australia",
"Austria",
"Azerbaijan",
"Bahamas",
"Bahrain",
"Bangladesh",
"Barbados",
"Belarus",
"Belgium",
"Belize",
"Benin",
"Bhutan",
"Bolivia",
"Bosnia and Herzegovina",
"Botswana",
"Brazil",
"Brunei",
"Bulgaria",
"Burkina Faso",
"Burundi",
"Cabo Verde",
"Cambodia",
"Cameroon",
"Canada",
"Central African Republic",
"Chad",
"Chile",
"China",
"Colombia",
"Comoros",
"Congo",
"Costa Rica",
"Croatia",
"Cuba",
"Cyprus",
"Czechia",
"Democratic Republic of the Congo",
"Denmark",
"Djibouti",
"Dominica",
"Dominican Republic",
"Ecuador",
"Egypt",
"El Salvador",
"Equatorial Guinea",
"Eritrea",
"Estonia",
"Eswatini",
"Ethiopia",
"Fiji",
"Finland",
"France",
"Gabon",
"Gambia",
"Georgia",
"Germany",
"Ghana",
"Greece",
"Grenada",
"Guatemala",
"Guinea",
"Guinea-Bissau",
"Guyana",
"Haiti",
"Honduras",
"Hungary",
"Iceland",
"India",
"Indonesia",
"Iran",
"Iraq",
"Ireland",
"Israel",
"Italy",
"Jamaica",
"Japan",
"Jordan",
"Kazakhstan",
"Kenya",
"Kiribati",
"Kuwait",
"Kyrgyzstan",
"Laos",
"Latvia",
"Lebanon",
"Lesotho",
"Liberia",
"Libya",
"Liechtenstein",
"Lithuania",
"Luxembourg",
"Madagascar",
"Malawi",
"Malaysia",
"Maldives",
"Mali",
"Malta",
"Marshall Islands",
"Mauritania",
"Mauritius",
"Mexico",
"Micronesia",
"Moldova",
"Monaco",
"Mongolia",
"Montenegro",
"Morocco",
"Mozambique",
"Myanmar",
"Namibia",
"Nauru",
"Nepal",
"Netherlands",
"New Zealand",
"Nicaragua",
"Niger",
"Nigeria",
"North Korea",
"North Macedonia",
"Norway",
"Oman",
"Pakistan",
"Palau",
"Panama",
"Papua New Guinea",
"Paraguay",
"Peru",
"Philippines",
"Poland",
"Portugal",
"Qatar",
"Romania",
"Russia",
"Rwanda",
"Saint Kitts and Nevis",
"Saint Lucia",
"Saint Vincent and the Grenadines",
"Samoa",
"San Marino",
"Sao Tome and Principe",
"Saudi Arabia",
"Senegal",
"Serbia",
"Seychelles",
"Sierra Leone",
"Singapore",
"Slovakia",
"Slovenia",
"Solomon Islands",
"Somalia",
"South Africa",
"South Korea",
"South Sudan",
"Spain",
"Sri Lanka",
"Sudan",
"Suriname",
"Sweden",
"Switzerland",
"Syria",
"Tajikistan",
"Tanzania",
"Thailand",
"Timor-Leste",
"Togo",
"Tonga",
"Trinidad and Tobago",
"Tunisia",
"Turkey",
"Turkmenistan",
"Tuvalu",
"Uganda",
"Ukraine",
"United Arab Emirates",
"United Kingdom",
"United States",
"Uruguay",
"Uzbekistan",
"Vanuatu",
"Vatican City",
"Venezuela",
"Vietnam",
"Yemen",
"Zambia",
"Zimbabwe",
] as const
/** English short names, AZ (for `<select>` options). */
export const USER_FILTER_COUNTRIES: readonly string[] = [...COUNTRY_NAMES_RAW].sort((a, b) =>
a.localeCompare(b, "en"),
)
/**
* Ethiopia regions & chartered cities (canonical spelling for filters).
* Backend matches case-insensitively; use these labels so UI aligns with stored data.
*/
export const USER_FILTER_ETHIOPIA_REGIONS = [
"Addis Ababa",
"Afar",
"Amhara",
"Benishangul-Gumuz",
"Dire Dawa",
"Gambela Peoples' Region",
"Harari",
"Oromia",
"Sidama",
"Somali",
"Southern Nations, Nationalities, and Peoples' Region",
"South West Ethiopia Peoples' Region",
"Tigray",
] as const satisfies readonly string[]
export type UserFilterEthiopiaRegion = (typeof USER_FILTER_ETHIOPIA_REGIONS)[number]

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react"
import { getPersonas } from "../api/personas.api"
import {
mapPersonaToCard,
unwrapPersonasList,
type PersonaCardModel,
} from "../lib/personaDisplay"
type UseActivePersonasOptions = {
limit?: number
offset?: number
}
export function useActivePersonas(options: UseActivePersonasOptions = {}) {
const { limit = 50, offset = 0 } = options
const [personas, setPersonas] = useState<PersonaCardModel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await getPersonas({ limit, offset })
const list = unwrapPersonasList(res).filter((p) => p.is_active)
setPersonas(list.map(mapPersonaToCard))
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to load personas"
setError(msg)
setPersonas([])
} finally {
setLoading(false)
}
}, [limit, offset])
useEffect(() => {
void load()
}, [load])
return { personas, loading, error, reload: load }
}

View File

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

View File

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

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

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

140
src/lib/analytics.ts Normal file
View File

@ -0,0 +1,140 @@
import type {
DashboardDateFilter,
DashboardSubscriptions,
DateRevenue,
LabelCount,
} from "../types/analytics.types"
const INACTIVE_SUBSCRIPTION_STATUSES = new Set([
"INACTIVE",
"CANCELLED",
"CANCELED",
"EXPIRED",
"PAUSED",
"SUSPENDED",
])
export interface SubscriptionMetrics {
total: number
active: number
inactive: number
}
/** Derives inactive count from by_status when present, else total active. */
export function getSubscriptionMetrics(
subscriptions: DashboardSubscriptions,
): SubscriptionMetrics {
const total = subscriptions.total_subscriptions ?? 0
const active = subscriptions.active_subscriptions ?? 0
let inactiveFromStatus = 0
if (subscriptions.by_status?.length) {
inactiveFromStatus = subscriptions.by_status
.filter((s) => INACTIVE_SUBSCRIPTION_STATUSES.has(s.label.toUpperCase()))
.reduce((sum, s) => sum + s.count, 0)
if (inactiveFromStatus === 0) {
inactiveFromStatus = subscriptions.by_status
.filter((s) => s.label.toUpperCase() !== "ACTIVE")
.reduce((sum, s) => sum + s.count, 0)
}
}
const inactive =
inactiveFromStatus > 0 ? inactiveFromStatus : Math.max(0, total - active)
return { total, active, inactive }
}
const MONTH_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const
function formatShortDate(iso: string) {
const d = new Date(iso)
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function formatBreakdownLabel(label: string) {
return label.replace(/_/g, " ").toLowerCase()
}
export function getPrimaryQuestionTypeSummary(questionsByType: LabelCount[]): string {
if (questionsByType.length === 0) return "No question types"
const top = [...questionsByType].sort((a, b) => b.count - a.count)[0]
return `${top.count.toLocaleString()} ${formatBreakdownLabel(top.label)}`
}
export function getVideoLessonsSummary(lmsLessonsWithVideo = 0, examPrepLessonsWithVideo = 0): string {
return `${lmsLessonsWithVideo.toLocaleString()} LMS · ${examPrepLessonsWithVideo.toLocaleString()} exam prep lessons`
}
export interface MonthlyRevenuePoint {
month: string
monthIndex: number
revenue: number
}
export function aggregateRevenueByMonth(daily: DateRevenue[], year: number): MonthlyRevenuePoint[] {
const monthly = Array.from({ length: 12 }, (_, monthIndex) => ({
month: MONTH_SHORT[monthIndex],
monthIndex,
revenue: 0,
}))
for (const { date, revenue } of daily) {
const parsed = new Date(date)
if (parsed.getUTCFullYear() !== year) continue
monthly[parsed.getUTCMonth()].revenue += revenue
}
return monthly
}
export function formatRevenueAxisTick(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)}M`
if (value >= 1_000) return `${Math.round(value / 1_000)}K`
return String(value)
}
export function getSeriesPeriodLabel(dateFilter?: DashboardDateFilter): string {
if (!dateFilter) return "Last 30 Days"
switch (dateFilter.mode) {
case "all_time":
return "Last 30 Days"
case "year":
return dateFilter.year != null ? String(dateFilter.year) : "Selected year"
case "year_month":
if (dateFilter.year != null && dateFilter.month != null) {
return `${MONTH_SHORT[dateFilter.month - 1]} ${dateFilter.year}`
}
return "Selected month"
case "custom":
if (dateFilter.from && dateFilter.to) {
return `${formatShortDate(dateFilter.from)} ${formatShortDate(dateFilter.to)}`
}
return "Custom range"
default:
return "Selected period"
}
}
/** Display label for dashboard breakdown rows (regions, enums, free text). */
export function formatAnalyticsLabel(label: string): string {
const text = label?.trim() ?? ""
if (!text || text.toLowerCase() === "unknown") return "Unknown"
if (text.includes("_")) return text.replace(/_/g, " ")
return text
}

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

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

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

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

View File

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

View File

@ -0,0 +1,56 @@
const SAMPLE_VALUES: Record<string, string> = {
OTP: "123456",
FirstName: "Alex",
ExpiresMinutes: "10",
ResetLink: "https://app.yimaruacademy.com/reset?token=sample",
InviteLink: "https://app.yimaruacademy.com/invite?token=sample",
InviterName: "Jordan Admin",
LoginURL: "https://app.yimaruacademy.com/login",
Subject: "Sample announcement subject",
Message:
"This is sample body text shown in the admin preview. Replace variables when sending real emails.",
}
function sampleForVariable(name: string) {
return SAMPLE_VALUES[name] ?? `[${name}]`
}
/** Best-effort preview: substitutes `{{.Var}}` and unwraps simple `{{if .Var}}...{{end}}` blocks. */
export function renderEmailTemplatePreview(
source: string,
variables: string[],
): string {
let result = source
for (const variable of variables) {
const sample = sampleForVariable(variable)
result = result.split(`{{.${variable}}}`).join(sample)
const ifBlock = new RegExp(
`\\{\\{if \\.${variable}\\}\\}([\\s\\S]*?)\\{\\{end\\}\\}`,
"g",
)
result = result.replace(ifBlock, "$1")
}
return result
}
export function formatEmailTemplateDate(raw: string | null | undefined) {
if (raw == null || String(raw).trim() === "") {
return "—"
}
const text = String(raw)
const parsed = new Date(text)
if (Number.isNaN(parsed.getTime())) {
return text.split(" +")[0]?.trim() || text
}
return parsed.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
}
export function emailTemplateStatusBadgeVariant(status: string) {
const normalized = status.toUpperCase()
if (normalized === "ACTIVE") return "success" as const
if (normalized === "INACTIVE") return "secondary" as const
return "info" as const
}

View File

@ -0,0 +1,548 @@
import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
import {
parseMultipleChoiceSlotValue,
serializeMultipleChoiceSlotValue,
defaultMultipleChoiceSlotValue,
validateMultipleChoiceSlotValue,
multipleChoiceSlotHasContent,
} from "./multipleChoiceSlotValue"
import {
defaultMatchingInputsSlotValue,
findMatchingInputsInFieldValues,
matchingAnswerSlotHasContent,
matchingInputsSlotHasContent,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
serializeMatchingAnswerSlotValue,
serializeMatchingInputsSlotValue,
validateMatchingAnswerSlotValue,
validateMatchingInputsSlotValue,
} from "./matchingSlotValue"
import {
defaultSelectMissingWordsStimulusSlotValue,
findSelectMissingWordsStimulusInFieldValues,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
selectMissingWordsResponseHasContent,
selectMissingWordsStimulusHasContent,
serializeSelectMissingWordsResponseSlotValue,
serializeSelectMissingWordsStimulusSlotValue,
validateSelectMissingWordsResponseSlotValue,
validateSelectMissingWordsStimulusSlotValue,
} from "./selectMissingWordsSlotValue"
function isMultipleChoiceKind(kind: string): boolean {
const u = kind.trim().toUpperCase()
return u === "MULTIPLE_CHOICE" || u === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function isStructuredDynamicSlotKind(kind: string): boolean {
return (
isMultipleChoiceKind(kind) ||
isMatchingInputsKind(kind) ||
isMatchingAnswerKind(kind) ||
isSelectMissingWordsKind(kind)
)
}
function defaultValueForSchemaSlot(
kind: string,
side: "stimulus" | "response",
): string {
const u = kind.trim().toUpperCase()
if (u === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
if (isMultipleChoiceKind(kind)) {
return serializeMultipleChoiceSlotValue(defaultMultipleChoiceSlotValue())
}
if (isMatchingInputsKind(kind)) {
return serializeMatchingInputsSlotValue(defaultMatchingInputsSlotValue())
}
if (isMatchingAnswerKind(kind)) {
return serializeMatchingAnswerSlotValue({ pairs: [] })
}
if (isSelectMissingWordsKind(kind)) {
if (side === "stimulus") {
return serializeSelectMissingWordsStimulusSlotValue(
defaultSelectMissingWordsStimulusSlotValue(),
)
}
return serializeSelectMissingWordsResponseSlotValue({ blanks: [] })
}
return ""
}
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
}
export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition,
): Record<string, string> {
const o: Record<string, string> = {}
for (const r of def.stimulus_schema) {
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind, "stimulus")
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind, "response")
}
return o
}
const PROMPT_STIMULUS_KINDS = new Set(["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"])
/** First stimulus slot used for a plain-text prompt shortcut in the practice UI. */
export function primaryPromptStimulusRow(
def: QuestionTypeDefinition,
): { id: string; kind: string } | null {
for (const kind of ["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"] as const) {
const row = def.stimulus_schema.find((r) => r.kind === kind)
if (row) return { id: row.id, kind: row.kind }
}
return def.stimulus_schema[0] ? { id: def.stimulus_schema[0].id, kind: def.stimulus_schema[0].kind } : null
}
export function mergePromptIntoDynamicFieldValues(
def: QuestionTypeDefinition,
questionText: string,
fieldValues: Record<string, string>,
): Record<string, string> {
const merged = { ...fieldValues }
const prompt = questionText.trim()
if (!prompt) return merged
const slot = primaryPromptStimulusRow(def)
if (!slot) return merged
const key = `stimulus:${slot.id}`
if (!merged[key]?.trim()) merged[key] = prompt
return merged
}
export function dynamicPromptFromFieldValues(
def: QuestionTypeDefinition,
fieldValues: Record<string, string>,
): string {
for (const row of def.stimulus_schema) {
if (!PROMPT_STIMULUS_KINDS.has(row.kind)) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (v) return v
}
return ""
}
/**
* System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
*/
export function legacyQuestionTypeFromDefinition(
def: QuestionTypeDefinition,
): "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | null {
if (definitionUsesDynamicPayload(def)) return null
const k = def.key.toLowerCase()
if (k === "multiple_choice") return "MCQ"
if (k === "true_false") return "TRUE_FALSE"
if (k === "short_answer" || k === "fill_in_the_blank") return "SHORT_ANSWER"
return null
}
export interface LearnEnglishDefinitionQuestionInput {
questionText: string
questionTypeDefinitionId: number
dynamicFieldValues: Record<string, string>
mcqOptions?: { option_text: string; is_correct: boolean }[]
trueFalseAnswerIsTrue?: boolean
shortAnswers?: string[]
voicePromptUrl?: string
sampleAnswerVoiceUrl?: string
}
export function questionRowHasContent(
q: LearnEnglishDefinitionQuestionInput,
def: QuestionTypeDefinition,
): boolean {
if (!definitionUsesDynamicPayload(def)) {
return Boolean(q.questionText.trim())
}
if (q.questionText.trim()) return true
const fv = q.dynamicFieldValues ?? {}
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsStimulusHasContent(
parseSelectMissingWordsStimulusSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`stimulus:${row.id}`]?.trim()) return true
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsResponseHasContent(
parseSelectMissingWordsResponseSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`response:${row.id}`]?.trim()) return true
}
return false
}
export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
status: "DRAFT" | "PUBLISHED",
): CreateQuestionRequest {
const difficulty = "EASY"
const points = 1
const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues,
mcqOptions: q.mcqOptions,
})
return {
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: payload,
}
}
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const options: QuestionOption[] = (q.mcqOptions ?? [])
.filter((o) => o.option_text.trim())
.map((o, idx) => ({
option_order: idx + 1,
option_text: o.option_text.trim(),
is_correct: o.is_correct,
}))
return {
question_text,
question_type: "MCQ",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "TRUE_FALSE") {
const trueCorrect = q.trueFalseAnswerIsTrue !== false
const options: QuestionOption[] = [
{ option_order: 1, option_text: "True", is_correct: trueCorrect },
{ option_order: 2, option_text: "False", is_correct: !trueCorrect },
]
return {
question_text,
question_type: "TRUE_FALSE",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "SHORT_ANSWER") {
const short_answers = (q.shortAnswers ?? [])
.map((s) => s.trim())
.filter(Boolean)
.map((acceptable_answer) => ({
acceptable_answer,
match_type: "CASE_INSENSITIVE" as const,
}))
return {
question_text,
question_type: "SHORT_ANSWER",
difficulty_level: difficulty,
points,
status,
short_answers,
}
}
return {
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: { stimulus: [], response: [] },
}
}
export function validateDefinitionQuestion(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
index1Based: number,
): string | null {
const n = index1Based
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const hasPrompt =
Boolean(q.questionText.trim()) || Boolean(dynamicPromptFromFieldValues(def, fieldValues))
const promptRow = def.stimulus_schema.find((r) => PROMPT_STIMULUS_KINDS.has(r.kind) && r.required)
if (promptRow && !hasPrompt) {
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
}
for (const row of def.stimulus_schema) {
if (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
}
for (const row of def.response_schema) {
if (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
const matchingInputs = findMatchingInputsInFieldValues(
fieldValues,
def.stimulus_schema,
def.response_schema,
)
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
fieldValues,
def.stimulus_schema,
)
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`stimulus:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for stimulus "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`stimulus:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsStimulusSlotValue(
fieldValues[`stimulus:${row.id}`],
)
if (!selectMissingWordsStimulusHasContent(val)) {
if (row.required) {
return `Question ${n}: add cloze passage for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsStimulusSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`response:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for response "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (response "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`response:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsResponseSlotValue(
fieldValues[`response:${row.id}`],
clozeStimulus,
)
if (!selectMissingWordsResponseHasContent(val)) {
if (row.required) {
return `Question ${n}: select words for each blank in response "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsResponseSlotValue(val, clozeStimulus)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
}
return null
}
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
if (opts.length < 2)
return `Question ${n} (${def.display_name}): add at least two choices with text.`
if (!opts.some((o) => o.is_correct))
return `Question ${n} (${def.display_name}): mark one correct choice.`
return null
}
if (legacy === "TRUE_FALSE") return null
if (legacy === "SHORT_ANSWER") {
const answers = (q.shortAnswers ?? []).map((s) => s.trim()).filter(Boolean)
if (answers.length < 1)
return `Question ${n} (${def.display_name}): add at least one acceptable answer.`
return null
}
return null
}

View File

@ -0,0 +1,164 @@
import type { AxiosError } from "axios"
import {
addQuestionToSet,
createExamPrepLessonPractice,
createParentLinkedPractice,
createQuestion,
createQuestionSet,
} from "../api/courses.api"
import type { PracticeParentKind } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import {
buildCreateQuestionFromDefinition,
questionRowHasContent,
validateDefinitionQuestion,
type LearnEnglishDefinitionQuestionInput,
} from "./learnEnglishDefinitionQuestion"
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
const ax = err as AxiosError<{ message?: string; error?: string }>
const data = ax.response?.data
if (data && typeof data === "object") {
const m = data.message ?? data.error
if (typeof m === "string" && m.trim()) return m.trim()
}
if (err instanceof Error && err.message) return err.message
return "Request failed"
}
export function validateLearnEnglishQuestionsWithDefinitions(
questions: LearnEnglishDefinitionQuestionInput[],
definitions: QuestionTypeDefinition[],
): string | null {
const byId = new Map(definitions.map((d) => [d.id, d]))
const filled = questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
if (filled.length === 0) return "Add at least one question with content."
for (let i = 0; i < filled.length; i++) {
const q = filled[i]
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
return `Question ${i + 1}: select a question type from the list.`
}
const def = byId.get(q.questionTypeDefinitionId)
if (!def) {
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
}
const err = validateDefinitionQuestion(def, q, i + 1)
if (err) return err
}
return null
}
/**
* Learn English parent-linked practice: create PRACTICE question set,
* create questions from GET /questions/type-definitions entries, attach them, POST /practices.
*/
export async function executeLearnEnglishPracticeCreation(opts: {
parentKind: PracticeParentKind
parentId: number
status: "DRAFT" | "PUBLISHED"
questionSetTitle: string
questionSetDescription?: string | null
shuffleQuestions: boolean
practiceTitle: string
storyDescription: string
storyImage: string
quickTips: string
personaName?: string | null
/** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
personaId: number
questions: LearnEnglishDefinitionQuestionInput[]
definitions: QuestionTypeDefinition[]
/** When set, links practice via POST /exam-prep/lessons/:id/practices instead of POST /practices. */
examPrepLessonId?: number
}): Promise<{ questionSetId: number; practiceId: number }> {
const err = validateLearnEnglishQuestionsWithDefinitions(
opts.questions,
opts.definitions,
)
if (err) throw new Error(err)
if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
throw new Error("persona_id is required. Select a persona before saving.")
}
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
const setRes = await createQuestionSet({
title: opts.questionSetTitle.trim() || "Practice question set",
description: opts.questionSetDescription?.trim() || null,
set_type: "PRACTICE",
owner_type: opts.parentKind,
owner_id: opts.parentId,
shuffle_questions: opts.shuffleQuestions,
status: opts.status,
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
})
const setId = setRes.data?.data?.id
if (!setId) {
throw new Error(
(setRes.data as { message?: string } | undefined)?.message ??
"Could not create question set",
)
}
const toCreate = opts.questions.filter((q) => {
const def = byId.get(q.questionTypeDefinitionId)
return def ? questionRowHasContent(q, def) : false
})
let displayOrder = 0
for (const q of toCreate) {
const def = byId.get(q.questionTypeDefinitionId)
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
displayOrder += 1
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
const qRes = await createQuestion(payload)
const questionId = qRes.data?.data?.id
if (!questionId) {
throw new Error(
(qRes.data as { message?: string } | undefined)?.message ??
"Could not create question",
)
}
await addQuestionToSet(setId, {
question_id: questionId,
display_order: displayOrder,
})
}
const practiceRes = opts.examPrepLessonId
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
persona_id: opts.personaId,
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
})
: await createParentLinkedPractice({
parent_kind: opts.parentKind,
parent_id: opts.parentId,
title: opts.practiceTitle.trim(),
story_description: opts.storyDescription.trim(),
story_image: opts.storyImage.trim(),
question_set_id: setId,
quick_tips: opts.quickTips.trim(),
publish_status: opts.status,
persona_id: opts.personaId,
})
const practiceId = practiceRes.data?.data?.id
if (!practiceId) {
throw new Error(
(practiceRes.data as { message?: string } | undefined)?.message ??
"Could not create practice",
)
}
return { questionSetId: setId, practiceId }
}

View File

@ -0,0 +1,354 @@
export interface MatchingSideItem {
id: string
text: string
}
export interface MatchingInputsSlotValue {
left: MatchingSideItem[]
right: MatchingSideItem[]
}
export interface MatchingPair {
left_id: string
right_id: string
}
export interface MatchingAnswerSlotValue {
pairs: MatchingPair[]
}
export const MATCHING_MIN_ITEMS = 2
const DEFAULT_INPUT_COUNT = 4
function reindexSide(
items: MatchingSideItem[],
prefix: "l" | "r",
): MatchingSideItem[] {
return items.map((item, index) => ({
id: `${prefix}${index + 1}`,
text: item.text,
}))
}
export function defaultMatchingInputsSlotValue(
count = DEFAULT_INPUT_COUNT,
): MatchingInputsSlotValue {
return {
left: Array.from({ length: count }, (_, index) => ({
id: `l${index + 1}`,
text: "",
})),
right: Array.from({ length: count }, (_, index) => ({
id: `r${index + 1}`,
text: "",
})),
}
}
export function defaultMatchingAnswerFromInputs(
inputs: MatchingInputsSlotValue,
): MatchingAnswerSlotValue {
const count = Math.min(inputs.left.length, inputs.right.length)
return {
pairs: Array.from({ length: count }, (_, index) => ({
left_id: inputs.left[index]?.id ?? `l${index + 1}`,
right_id: inputs.right[index]?.id ?? `r${index + 1}`,
})),
}
}
export function serializeMatchingInputsSlotValue(
value: MatchingInputsSlotValue,
): string {
return JSON.stringify(value)
}
export function serializeMatchingAnswerSlotValue(
value: MatchingAnswerSlotValue,
): string {
return JSON.stringify(value)
}
export function matchingItemHasValue(text: string): boolean {
return text.length > 0
}
function normalizeSideItem(raw: unknown, index: number, prefix: "l" | "r"): MatchingSideItem {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? `${prefix}${index + 1}`),
text: String(record.text ?? ""),
}
}
return {
id: `${prefix}${index + 1}`,
text: String(raw ?? ""),
}
}
export function normalizeMatchingInputsValue(raw: unknown): MatchingInputsSlotValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
const left = Array.isArray(record.left)
? record.left.map((item, index) => normalizeSideItem(item, index, "l"))
: []
const right = Array.isArray(record.right)
? record.right.map((item, index) => normalizeSideItem(item, index, "r"))
: []
if (left.length > 0 || right.length > 0) {
return ensureMinMatchingInputs({ left, right })
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMatchingInputsValue(JSON.parse(raw) as unknown)
} catch {
return defaultMatchingInputsSlotValue()
}
}
return defaultMatchingInputsSlotValue()
}
function normalizePair(raw: unknown, index: number): MatchingPair {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
left_id: String(record.left_id ?? record.leftId ?? `l${index + 1}`),
right_id: String(record.right_id ?? record.rightId ?? `r${index + 1}`),
}
}
return {
left_id: `l${index + 1}`,
right_id: `r${index + 1}`,
}
}
export function normalizeMatchingAnswerValue(raw: unknown): MatchingAnswerSlotValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.pairs)) {
return {
pairs: record.pairs.map((pair, index) => normalizePair(pair, index)),
}
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMatchingAnswerValue(JSON.parse(raw) as unknown)
} catch {
return { pairs: [] }
}
}
return { pairs: [] }
}
export function ensureMinMatchingInputs(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
const left = [...value.left]
const right = [...value.right]
while (left.length < MATCHING_MIN_ITEMS) {
left.push({ id: `l${left.length + 1}`, text: "" })
}
while (right.length < MATCHING_MIN_ITEMS) {
right.push({ id: `r${right.length + 1}`, text: "" })
}
return {
left: reindexSide(left, "l"),
right: reindexSide(right, "r"),
}
}
export function parseMatchingInputsSlotValue(
raw: string | undefined,
): MatchingInputsSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultMatchingInputsSlotValue()
try {
return ensureMinMatchingInputs(
normalizeMatchingInputsValue(JSON.parse(trimmed) as unknown),
)
} catch {
return defaultMatchingInputsSlotValue()
}
}
export function parseMatchingAnswerSlotValue(
raw: string | undefined,
inputs?: MatchingInputsSlotValue | null,
): MatchingAnswerSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) {
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
}
try {
const parsed = normalizeMatchingAnswerValue(JSON.parse(trimmed) as unknown)
if (parsed.pairs.length > 0) return parsed
return inputs ? defaultMatchingAnswerFromInputs(inputs) : parsed
} catch {
return inputs ? defaultMatchingAnswerFromInputs(inputs) : { pairs: [] }
}
}
export function matchingInputsSlotHasContent(
value: MatchingInputsSlotValue,
): boolean {
return (
value.left.some((item) => matchingItemHasValue(item.text)) ||
value.right.some((item) => matchingItemHasValue(item.text))
)
}
export function matchingAnswerSlotHasContent(
value: MatchingAnswerSlotValue,
): boolean {
return value.pairs.some(
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
)
}
export function finalizeMatchingInputsPayload(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
return {
left: value.left
.filter((item) => matchingItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
right: value.right
.filter((item) => matchingItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
}
}
export function finalizeMatchingAnswerPayload(
value: MatchingAnswerSlotValue,
): MatchingAnswerSlotValue {
return {
pairs: value.pairs
.filter(
(pair) => pair.left_id.trim().length > 0 && pair.right_id.trim().length > 0,
)
.map((pair) => ({
left_id: pair.left_id,
right_id: pair.right_id,
})),
}
}
export function addMatchingInputRow(
value: MatchingInputsSlotValue,
): MatchingInputsSlotValue {
return ensureMinMatchingInputs({
left: [...value.left, { id: `l${value.left.length + 1}`, text: "" }],
right: [...value.right, { id: `r${value.right.length + 1}`, text: "" }],
})
}
export function removeMatchingInputRow(
value: MatchingInputsSlotValue,
index: number,
): MatchingInputsSlotValue {
if (value.left.length <= MATCHING_MIN_ITEMS) return value
return ensureMinMatchingInputs({
left: value.left.filter((_, i) => i !== index),
right: value.right.filter((_, i) => i !== index),
})
}
export function addMatchingPair(
value: MatchingAnswerSlotValue,
inputs?: MatchingInputsSlotValue | null,
): MatchingAnswerSlotValue {
const left = inputs?.left ?? []
const right = inputs?.right ?? []
const nextIndex = value.pairs.length
return {
pairs: [
...value.pairs,
{
left_id: left[nextIndex]?.id ?? left[0]?.id ?? "l1",
right_id: right[nextIndex]?.id ?? right[0]?.id ?? "r1",
},
],
}
}
export function removeMatchingPair(
value: MatchingAnswerSlotValue,
index: number,
): MatchingAnswerSlotValue {
if (value.pairs.length <= 1) return value
return {
pairs: value.pairs.filter((_, i) => i !== index),
}
}
export function validateMatchingInputsSlotValue(
value: MatchingInputsSlotValue,
): string | null {
if (
value.left.length < MATCHING_MIN_ITEMS ||
value.right.length < MATCHING_MIN_ITEMS
) {
return `Add at least ${MATCHING_MIN_ITEMS} items on each side.`
}
const filledLeft = value.left.filter((item) => matchingItemHasValue(item.text))
const filledRight = value.right.filter((item) => matchingItemHasValue(item.text))
if (filledLeft.length < MATCHING_MIN_ITEMS) {
return `Add at least ${MATCHING_MIN_ITEMS} left-side items with text.`
}
if (filledRight.length < MATCHING_MIN_ITEMS) {
return `Add at least ${MATCHING_MIN_ITEMS} right-side items with text.`
}
return null
}
export function validateMatchingAnswerSlotValue(
value: MatchingAnswerSlotValue,
inputs?: MatchingInputsSlotValue | null,
): string | null {
const pairs = value.pairs.filter(
(pair) => pair.left_id.trim() && pair.right_id.trim(),
)
if (pairs.length < 1) return "Add at least one matching pair."
const leftIds = new Set(inputs?.left.map((item) => item.id) ?? [])
const rightIds = new Set(inputs?.right.map((item) => item.id) ?? [])
const usedLeft = new Set<string>()
for (const pair of pairs) {
if (inputs && leftIds.size > 0 && !leftIds.has(pair.left_id)) {
return `Unknown left id "${pair.left_id}" in matching answer.`
}
if (inputs && rightIds.size > 0 && !rightIds.has(pair.right_id)) {
return `Unknown right id "${pair.right_id}" in matching answer.`
}
if (usedLeft.has(pair.left_id)) {
return `Duplicate left id "${pair.left_id}" in matching answer.`
}
usedLeft.add(pair.left_id)
}
return null
}
export function findMatchingInputsInFieldValues(
fieldValues: Record<string, string>,
stimulusSchema: { id: string; kind: string }[],
responseSchema: { id: string; kind: string }[],
): MatchingInputsSlotValue | null {
for (const row of stimulusSchema) {
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
const parsed = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
if (matchingInputsSlotHasContent(parsed)) return parsed
}
for (const row of responseSchema) {
if (row.kind.trim().toUpperCase() !== "MATCHING_INPUTS") continue
const parsed = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
if (matchingInputsSlotHasContent(parsed)) return parsed
}
return null
}

View File

@ -0,0 +1,197 @@
export interface MultipleChoiceOptionValue {
id: string
text: string
is_correct: boolean
}
export interface MultipleChoiceSlotValue {
options: MultipleChoiceOptionValue[]
}
const DEFAULT_OPTION_IDS = ["a", "b", "c", "d", "e", "f", "g", "h"] as const
export const MULTIPLE_CHOICE_MIN_OPTIONS = 2
export function defaultMultipleChoiceSlotValue(
count = MULTIPLE_CHOICE_MIN_OPTIONS,
): MultipleChoiceSlotValue {
return {
options: Array.from({ length: count }, (_, index) => ({
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: "",
is_correct: index === 0,
})),
}
}
export function serializeMultipleChoiceSlotValue(
value: MultipleChoiceSlotValue,
): string {
return JSON.stringify(value)
}
export function nextMultipleChoiceOptionId(
existing: MultipleChoiceOptionValue[],
): string {
const used = new Set(existing.map((option) => option.id))
for (const id of DEFAULT_OPTION_IDS) {
if (!used.has(id)) return id
}
return String(existing.length + 1)
}
export function addMultipleChoiceOption(
value: MultipleChoiceSlotValue,
): MultipleChoiceSlotValue {
return {
options: [
...value.options,
{
id: nextMultipleChoiceOptionId(value.options),
text: "",
is_correct: false,
},
],
}
}
export function removeMultipleChoiceOption(
value: MultipleChoiceSlotValue,
index: number,
): MultipleChoiceSlotValue {
if (value.options.length <= MULTIPLE_CHOICE_MIN_OPTIONS) return value
const removed = value.options[index]
let options = value.options.filter((_, i) => i !== index)
if (
removed?.is_correct &&
options.length > 0 &&
!options.some((option) => option.is_correct)
) {
options = options.map((option, i) => ({
...option,
is_correct: i === 0,
}))
}
return { options }
}
export function ensureMinMultipleChoiceOptions(
value: MultipleChoiceSlotValue,
): MultipleChoiceSlotValue {
if (value.options.length >= MULTIPLE_CHOICE_MIN_OPTIONS) return value
const options = [...value.options]
while (options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
options.push({
id: nextMultipleChoiceOptionId(options),
text: "",
is_correct: options.length === 0,
})
}
return { options }
}
export function multipleChoiceOptionHasValue(text: string): boolean {
return text.length > 0
}
export function parseMultipleChoiceSlotValue(
raw: string | undefined,
): MultipleChoiceSlotValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultMultipleChoiceSlotValue()
try {
const parsed = JSON.parse(trimmed) as unknown
return ensureMinMultipleChoiceOptions(normalizeMultipleChoiceValue(parsed))
} catch {
return defaultMultipleChoiceSlotValue()
}
}
export function normalizeMultipleChoiceValue(
raw: unknown,
mcqOptions?: { option_text?: string; text?: string; is_correct?: boolean; isCorrect?: boolean }[],
): MultipleChoiceSlotValue {
if (mcqOptions?.some((o) => multipleChoiceOptionHasValue(o.option_text ?? o.text ?? ""))) {
return {
options: mcqOptions
.map((option, index) => ({
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: option.option_text ?? option.text ?? "",
is_correct: Boolean(option.is_correct ?? option.isCorrect),
}))
.filter((option) => multipleChoiceOptionHasValue(option.text)),
}
}
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.options)) {
return {
options: record.options.map((option, index) =>
normalizeMultipleChoiceOption(option, index),
),
}
}
}
if (Array.isArray(raw)) {
return {
options: raw.map((option, index) =>
normalizeMultipleChoiceOption(option, index),
),
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeMultipleChoiceValue(JSON.parse(raw) as unknown)
} catch {
return defaultMultipleChoiceSlotValue()
}
}
return defaultMultipleChoiceSlotValue()
}
function normalizeMultipleChoiceOption(
raw: unknown,
index: number,
): MultipleChoiceOptionValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? DEFAULT_OPTION_IDS[index] ?? index + 1),
text: String(record.text ?? record.option_text ?? ""),
is_correct: Boolean(record.is_correct ?? record.isCorrect),
}
}
return {
id: DEFAULT_OPTION_IDS[index] ?? String(index + 1),
text: String(raw ?? ""),
is_correct: false,
}
}
export function multipleChoiceSlotHasContent(
value: MultipleChoiceSlotValue,
): boolean {
return value.options.some((option) => multipleChoiceOptionHasValue(option.text))
}
export function validateMultipleChoiceSlotValue(
value: MultipleChoiceSlotValue,
): string | null {
if (value.options.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices.`
}
const filled = value.options.filter((option) =>
multipleChoiceOptionHasValue(option.text),
)
if (filled.length < MULTIPLE_CHOICE_MIN_OPTIONS) {
return `Add at least ${MULTIPLE_CHOICE_MIN_OPTIONS} choices with text.`
}
if (!filled.some((option) => option.is_correct)) {
return "Mark one choice as correct."
}
return null
}

View File

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

View File

@ -0,0 +1,48 @@
import type {
GetPracticesByParentContextResponse,
ParentContextPractice,
PracticePublishStatus,
} from "../types/course.types"
export function unwrapPracticesList(
res: {
data?: GetPracticesByParentContextResponse & {
Data?: GetPracticesByParentContextResponse["data"]
}
},
): ParentContextPractice[] {
const body = res.data
if (!body) return []
const data = body.data ?? body.Data
const raw = data?.practices
return Array.isArray(raw) ? raw : []
}
export function practicePublishStatus(
practice: ParentContextPractice,
): PracticePublishStatus | null {
const raw = practice.publish_status
if (raw === "DRAFT" || raw === "PUBLISHED") return raw
if (typeof raw === "string") {
const upper = raw.toUpperCase()
if (upper === "DRAFT" || upper === "PUBLISHED") {
return upper as PracticePublishStatus
}
}
return null
}
export function isPracticePublished(practice: ParentContextPractice): boolean {
return practicePublishStatus(practice) === "PUBLISHED"
}
export function isPracticeDraft(practice: ParentContextPractice): boolean {
const status = practicePublishStatus(practice)
return status === "DRAFT" || status === null
}
export function draftPracticesForParent(
practices: ParentContextPractice[],
): ParentContextPractice[] {
return practices.filter(isPracticeDraft)
}

View File

@ -0,0 +1,27 @@
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/** Parse one or more emails from newline-, comma-, or semicolon-separated input. */
export function parseInviteEmails(text: string): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const part of text.split(/[\n,;]+/)) {
const email = part.trim().toLowerCase()
if (!email || seen.has(email)) continue
seen.add(email)
result.push(email)
}
return result
}
export function isValidInviteEmail(email: string): boolean {
return EMAIL_PATTERN.test(email)
}
export type InviteEmailSendResult = {
email: string
success: boolean
message: string
invitationId?: number
}

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

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

52
src/lib/personaDisplay.ts Normal file
View File

@ -0,0 +1,52 @@
import type {
GetPersonasResponse,
PersonaListItem,
} from "../types/persona.types"
export type PersonaCardModel = {
id: string
name: string
description: string
avatar: string
}
/** Soft, professional palette aligned with the admin brand (slate, indigo, violet). */
const PERSONA_FALLBACK_BACKGROUNDS = "f1f5f9,e0e7ff,ede9fe,fdf4ff,ecfeff"
/**
* Default avatar when `profile_picture` is null: professional illustrated portrait
* (DiceBear personas), not casual cartoon avataaars.
*/
export function personaAvatarUrl(
profilePicture: string | null | undefined,
name: string,
personaId?: number | string,
): string {
const url = profilePicture?.trim()
if (url) return url
const params = new URLSearchParams({
seed: personaId != null ? `yimaru-persona-${personaId}` : `yimaru-persona-${name}`,
backgroundColor: PERSONA_FALLBACK_BACKGROUNDS,
radius: "50",
})
return `https://api.dicebear.com/7.x/personas/svg?${params.toString()}`
}
export function mapPersonaToCard(persona: PersonaListItem): PersonaCardModel {
return {
id: String(persona.id),
name: persona.name,
description: persona.description?.trim() ?? "",
avatar: personaAvatarUrl(persona.profile_picture, persona.name, persona.id),
}
}
export function unwrapPersonasList(
res: { data?: GetPersonasResponse & { Data?: GetPersonasResponse["data"] } },
): PersonaListItem[] {
const body = res.data
if (!body) return []
const data = body.data ?? body.Data
const raw = data?.personas
return Array.isArray(raw) ? raw : []
}

View File

@ -0,0 +1,184 @@
import type { DynamicQuestionPayload } from "../types/questionTypeDefinition.types"
import {
finalizeMatchingAnswerPayload,
finalizeMatchingInputsPayload,
findMatchingInputsInFieldValues,
matchingAnswerSlotHasContent,
matchingInputsSlotHasContent,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
} from "./matchingSlotValue"
import {
finalizeSelectMissingWordsResponsePayload,
finalizeSelectMissingWordsStimulusPayload,
findSelectMissingWordsStimulusInFieldValues,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
selectMissingWordsResponseHasContent,
selectMissingWordsStimulusHasContent,
} from "./selectMissingWordsSlotValue"
import {
multipleChoiceOptionHasValue,
multipleChoiceSlotHasContent,
normalizeMultipleChoiceValue,
parseMultipleChoiceSlotValue,
} from "./multipleChoiceSlotValue"
/** Parse a single slot value: plain string/URL, or JSON object/array when input looks like JSON. */
export function parseDynamicSlotValue(raw: string | undefined): unknown {
const t = (raw ?? "").trim()
if (!t) return ""
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
return JSON.parse(t) as unknown
} catch {
return t
}
}
return t
}
const PLAIN_TEXT_STIMULUS_KINDS = new Set([
"INSTRUCTION",
"QUESTION_TEXT",
"TEXT_PASSAGE",
"TEXT",
"TEXT_INPUT",
])
function isMultipleChoiceKind(kind: string): boolean {
const upper = kind.trim().toUpperCase()
return upper === "MULTIPLE_CHOICE" || upper === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function slotValueForRow(
row: { id: string; kind: string },
side: "stimulus" | "response",
fieldValues: Record<string, string>,
mcqOptions: { option_text: string; is_correct: boolean }[] | undefined,
mcqOptionsConsumed: { current: boolean },
stimulusRows: { id: string; kind: string }[],
responseRows: { id: string; kind: string }[],
): unknown {
const fieldKey = `${side}:${row.id}`
const rawField = fieldValues[fieldKey]
if (isMultipleChoiceKind(row.kind)) {
const fromField = parseMultipleChoiceSlotValue(rawField)
if (multipleChoiceSlotHasContent(fromField)) {
return {
options: fromField.options
.filter((option) => multipleChoiceOptionHasValue(option.text))
.map((option) => ({
id: option.id,
text: option.text,
is_correct: option.is_correct,
})),
}
}
if (mcqOptions && !mcqOptionsConsumed.current) {
mcqOptionsConsumed.current = true
return normalizeMultipleChoiceValue(undefined, mcqOptions)
}
return { options: [] }
}
if (isMatchingInputsKind(row.kind)) {
const fromField = parseMatchingInputsSlotValue(rawField)
if (matchingInputsSlotHasContent(fromField)) {
return finalizeMatchingInputsPayload(fromField)
}
return { left: [], right: [] }
}
if (isMatchingAnswerKind(row.kind)) {
const matchingInputs = findMatchingInputsInFieldValues(
fieldValues,
stimulusRows,
responseRows,
)
const fromField = parseMatchingAnswerSlotValue(rawField, matchingInputs)
if (matchingAnswerSlotHasContent(fromField)) {
return finalizeMatchingAnswerPayload(fromField)
}
return { pairs: [] }
}
if (isSelectMissingWordsKind(row.kind)) {
if (side === "stimulus") {
const fromField = parseSelectMissingWordsStimulusSlotValue(rawField)
if (selectMissingWordsStimulusHasContent(fromField)) {
return finalizeSelectMissingWordsStimulusPayload(fromField)
}
return { segments: [], word_bank: [], allow_reuse: false }
}
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
fieldValues,
stimulusRows,
)
const fromField = parseSelectMissingWordsResponseSlotValue(
rawField,
clozeStimulus,
)
if (selectMissingWordsResponseHasContent(fromField)) {
return finalizeSelectMissingWordsResponsePayload(fromField)
}
return { blanks: [] }
}
if (side === "stimulus" && PLAIN_TEXT_STIMULUS_KINDS.has(row.kind.trim().toUpperCase())) {
return (rawField ?? "").trim()
}
return parseDynamicSlotValue(rawField)
}
export function buildDynamicQuestionPayload(input: {
stimulusRows: { id: string; kind: string }[]
responseRows: { id: string; kind: string }[]
fieldValues: Record<string, string>
mcqOptions?: { option_text: string; is_correct: boolean }[]
}): DynamicQuestionPayload {
const mcqOptionsConsumed = { current: false }
return {
stimulus: input.stimulusRows.map((row) => ({
id: row.id,
kind: row.kind,
value: slotValueForRow(
row,
"stimulus",
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
input.stimulusRows,
input.responseRows,
),
})),
response: input.responseRows.map((row) => ({
id: row.id,
kind: row.kind,
value: slotValueForRow(
row,
"response",
input.fieldValues,
input.mcqOptions,
mcqOptionsConsumed,
input.stimulusRows,
input.responseRows,
),
})),
}
}

View File

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

View File

@ -0,0 +1,524 @@
export interface ClozeTextSegment {
type: "text"
value: string
}
export interface ClozeBlankSegment {
type: "blank"
id: string
}
export type ClozeSegment = ClozeTextSegment | ClozeBlankSegment
export interface WordBankItem {
id: string
text: string
}
export interface SelectMissingWordsStimulusValue {
segments: ClozeSegment[]
word_bank: WordBankItem[]
allow_reuse: boolean
}
export interface ClozeBlankAnswer {
blank_id: string
text: string
word_id: string
}
export interface SelectMissingWordsResponseValue {
blanks: ClozeBlankAnswer[]
}
export const SELECT_MISSING_WORDS_MIN_BANK = 2
export const SELECT_MISSING_WORDS_MIN_BLANKS = 1
const DEFAULT_BLANK_COUNT = 2
const DEFAULT_WORD_BANK_COUNT = 4
function reindexBlankSegments(segments: ClozeSegment[]): ClozeSegment[] {
let blankIndex = 0
return segments.map((segment) => {
if (segment.type !== "blank") return segment
blankIndex += 1
return { type: "blank", id: `b${blankIndex}` }
})
}
function reindexWordBank(items: WordBankItem[]): WordBankItem[] {
return items.map((item, index) => ({
id: `w${index + 1}`,
text: item.text,
}))
}
function normalizeTextSegment(raw: unknown, index: number): ClozeTextSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "text") {
return {
type: "text",
value: String(record.value ?? ""),
}
}
}
return { type: "text", value: index === 0 ? "" : "" }
}
function normalizeBlankSegment(raw: unknown, index: number): ClozeBlankSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "blank") {
return {
type: "blank",
id: String(record.id ?? `b${index + 1}`),
}
}
}
return { type: "blank", id: `b${index + 1}` }
}
function normalizeSegment(raw: unknown, index: number): ClozeSegment {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (record.type === "blank") return normalizeBlankSegment(raw, index)
if (record.type === "text") return normalizeTextSegment(raw, index)
}
return { type: "text", value: "" }
}
function normalizeWordBankItem(raw: unknown, index: number): WordBankItem {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
id: String(record.id ?? `w${index + 1}`),
text: String(record.text ?? ""),
}
}
return { id: `w${index + 1}`, text: "" }
}
export function defaultSelectMissingWordsStimulusSlotValue(
blankCount = DEFAULT_BLANK_COUNT,
wordBankCount = DEFAULT_WORD_BANK_COUNT,
): SelectMissingWordsStimulusValue {
const segments: ClozeSegment[] = [{ type: "text", value: "" }]
for (let index = 0; index < blankCount; index += 1) {
segments.push({ type: "blank", id: `b${index + 1}` })
segments.push({ type: "text", value: "" })
}
return {
segments,
word_bank: Array.from({ length: wordBankCount }, (_, index) => ({
id: `w${index + 1}`,
text: "",
})),
allow_reuse: false,
}
}
export function blankIdsFromStimulus(
stimulus: SelectMissingWordsStimulusValue,
): string[] {
return stimulus.segments
.filter((segment): segment is ClozeBlankSegment => segment.type === "blank")
.map((segment) => segment.id)
}
export function defaultSelectMissingWordsResponseFromStimulus(
stimulus: SelectMissingWordsStimulusValue,
): SelectMissingWordsResponseValue {
return {
blanks: blankIdsFromStimulus(stimulus).map((blankId) => ({
blank_id: blankId,
text: "",
word_id: "",
})),
}
}
export function serializeSelectMissingWordsStimulusSlotValue(
value: SelectMissingWordsStimulusValue,
): string {
return JSON.stringify(value)
}
export function serializeSelectMissingWordsResponseSlotValue(
value: SelectMissingWordsResponseValue,
): string {
return JSON.stringify(value)
}
export function wordBankItemHasValue(text: string): boolean {
return text.length > 0
}
function ensureMinWordBank(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const wordBank = [...value.word_bank]
while (wordBank.length < SELECT_MISSING_WORDS_MIN_BANK) {
wordBank.push({ id: `w${wordBank.length + 1}`, text: "" })
}
return {
...value,
segments:
value.segments.length > 0
? reindexBlankSegments(value.segments)
: defaultSelectMissingWordsStimulusSlotValue().segments,
word_bank: reindexWordBank(wordBank),
}
}
export function normalizeSelectMissingWordsStimulusValue(
raw: unknown,
): SelectMissingWordsStimulusValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
const segments = Array.isArray(record.segments)
? record.segments.map((segment, index) => normalizeSegment(segment, index))
: []
const wordBank = Array.isArray(record.word_bank)
? record.word_bank.map((item, index) => normalizeWordBankItem(item, index))
: []
if (segments.length > 0 || wordBank.length > 0) {
return ensureMinWordBank({
segments,
word_bank: wordBank,
allow_reuse: Boolean(record.allow_reuse),
})
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeSelectMissingWordsStimulusValue(JSON.parse(raw) as unknown)
} catch {
return defaultSelectMissingWordsStimulusSlotValue()
}
}
return defaultSelectMissingWordsStimulusSlotValue()
}
function normalizeBlankAnswer(raw: unknown, index: number): ClozeBlankAnswer {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
return {
blank_id: String(record.blank_id ?? record.blankId ?? `b${index + 1}`),
text: String(record.text ?? ""),
word_id: String(record.word_id ?? record.wordId ?? ""),
}
}
return { blank_id: `b${index + 1}`, text: "", word_id: "" }
}
export function normalizeSelectMissingWordsResponseValue(
raw: unknown,
): SelectMissingWordsResponseValue {
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>
if (Array.isArray(record.blanks)) {
return {
blanks: record.blanks.map((blank, index) =>
normalizeBlankAnswer(blank, index),
),
}
}
}
if (typeof raw === "string" && raw.trim()) {
try {
return normalizeSelectMissingWordsResponseValue(JSON.parse(raw) as unknown)
} catch {
return { blanks: [] }
}
}
return { blanks: [] }
}
export function parseSelectMissingWordsStimulusSlotValue(
raw: string | undefined,
): SelectMissingWordsStimulusValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) return defaultSelectMissingWordsStimulusSlotValue()
try {
return ensureMinWordBank(
normalizeSelectMissingWordsStimulusValue(JSON.parse(trimmed) as unknown),
)
} catch {
return defaultSelectMissingWordsStimulusSlotValue()
}
}
export function parseSelectMissingWordsResponseSlotValue(
raw: string | undefined,
stimulus?: SelectMissingWordsStimulusValue | null,
): SelectMissingWordsResponseValue {
const trimmed = (raw ?? "").trim()
if (!trimmed) {
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: { blanks: [] }
}
try {
const parsed = normalizeSelectMissingWordsResponseValue(
JSON.parse(trimmed) as unknown,
)
if (parsed.blanks.length > 0) return parsed
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: parsed
} catch {
return stimulus
? defaultSelectMissingWordsResponseFromStimulus(stimulus)
: { blanks: [] }
}
}
export function selectMissingWordsStimulusHasContent(
value: SelectMissingWordsStimulusValue,
): boolean {
return (
value.segments.some(
(segment) =>
segment.type === "blank" ||
(segment.type === "text" && segment.value.length > 0),
) || value.word_bank.some((item) => wordBankItemHasValue(item.text))
)
}
export function selectMissingWordsResponseHasContent(
value: SelectMissingWordsResponseValue,
): boolean {
return value.blanks.some(
(blank) =>
blank.blank_id.trim().length > 0 &&
blank.word_id.trim().length > 0 &&
blank.text.length > 0,
)
}
export function finalizeSelectMissingWordsStimulusPayload(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
return {
segments: value.segments.map((segment) =>
segment.type === "text"
? { type: "text", value: segment.value }
: { type: "blank", id: segment.id },
),
word_bank: value.word_bank
.filter((item) => wordBankItemHasValue(item.text))
.map((item) => ({ id: item.id, text: item.text })),
allow_reuse: value.allow_reuse,
}
}
export function finalizeSelectMissingWordsResponsePayload(
value: SelectMissingWordsResponseValue,
): SelectMissingWordsResponseValue {
return {
blanks: value.blanks
.filter(
(blank) =>
blank.blank_id.trim().length > 0 &&
blank.word_id.trim().length > 0 &&
blank.text.length > 0,
)
.map((blank) => ({
blank_id: blank.blank_id,
text: blank.text,
word_id: blank.word_id,
})),
}
}
export function addWordBankItem(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const next = ensureMinWordBank(value)
return {
...next,
word_bank: reindexWordBank([
...next.word_bank,
{ id: `w${next.word_bank.length + 1}`, text: "" },
]),
}
}
export function removeWordBankItem(
value: SelectMissingWordsStimulusValue,
index: number,
): SelectMissingWordsStimulusValue {
if (value.word_bank.length <= SELECT_MISSING_WORDS_MIN_BANK) return value
return ensureMinWordBank({
...value,
word_bank: value.word_bank.filter((_, itemIndex) => itemIndex !== index),
})
}
export function addTextSegment(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
return {
...value,
segments: [...value.segments, { type: "text", value: "" }],
}
}
export function addBlankSegment(
value: SelectMissingWordsStimulusValue,
): SelectMissingWordsStimulusValue {
const blankCount = blankIdsFromStimulus(value).length
return ensureMinWordBank({
...value,
segments: [
...value.segments,
{ type: "blank", id: `b${blankCount + 1}` },
],
})
}
export function removeSegment(
value: SelectMissingWordsStimulusValue,
index: number,
): SelectMissingWordsStimulusValue {
if (value.segments.length <= 1) return value
return ensureMinWordBank({
...value,
segments: value.segments.filter((_, segmentIndex) => segmentIndex !== index),
})
}
export function updateTextSegment(
value: SelectMissingWordsStimulusValue,
index: number,
text: string,
): SelectMissingWordsStimulusValue {
const segments = [...value.segments]
const segment = segments[index]
if (!segment || segment.type !== "text") return value
segments[index] = { type: "text", value: text }
return { ...value, segments }
}
export function updateWordBankText(
value: SelectMissingWordsStimulusValue,
index: number,
text: string,
): SelectMissingWordsStimulusValue {
const wordBank = [...value.word_bank]
if (!wordBank[index]) return value
wordBank[index] = { ...wordBank[index], text }
return { ...value, word_bank: wordBank }
}
export function setAllowReuse(
value: SelectMissingWordsStimulusValue,
allowReuse: boolean,
): SelectMissingWordsStimulusValue {
return { ...value, allow_reuse: allowReuse }
}
export function syncResponseBlanksWithStimulus(
response: SelectMissingWordsResponseValue,
stimulus: SelectMissingWordsStimulusValue,
): SelectMissingWordsResponseValue {
const blankIds = blankIdsFromStimulus(stimulus)
const existing = new Map(
response.blanks.map((blank) => [blank.blank_id, blank]),
)
return {
blanks: blankIds.map((blankId) => {
const current = existing.get(blankId)
return (
current ?? {
blank_id: blankId,
text: "",
word_id: "",
}
)
}),
}
}
export function validateSelectMissingWordsStimulusSlotValue(
value: SelectMissingWordsStimulusValue,
): string | null {
const blankCount = blankIdsFromStimulus(value).length
if (blankCount < SELECT_MISSING_WORDS_MIN_BLANKS) {
return `Add at least ${SELECT_MISSING_WORDS_MIN_BLANKS} blank in the passage.`
}
const filledWords = value.word_bank.filter((item) =>
wordBankItemHasValue(item.text),
)
if (filledWords.length < SELECT_MISSING_WORDS_MIN_BANK) {
return `Add at least ${SELECT_MISSING_WORDS_MIN_BANK} words in the word bank.`
}
return null
}
export function validateSelectMissingWordsResponseSlotValue(
value: SelectMissingWordsResponseValue,
stimulus?: SelectMissingWordsStimulusValue | null,
): string | null {
const filled = value.blanks.filter(
(blank) =>
blank.blank_id.trim() &&
blank.word_id.trim() &&
blank.text.length > 0,
)
if (filled.length < SELECT_MISSING_WORDS_MIN_BLANKS) {
return "Select a word for each blank."
}
const blankIds = new Set(stimulus ? blankIdsFromStimulus(stimulus) : [])
const wordIds = new Set(
stimulus?.word_bank
.filter((item) => wordBankItemHasValue(item.text))
.map((item) => item.id) ?? [],
)
const wordTextById = new Map(
stimulus?.word_bank.map((item) => [item.id, item.text]) ?? [],
)
for (const blank of filled) {
if (blankIds.size > 0 && !blankIds.has(blank.blank_id)) {
return `Unknown blank id "${blank.blank_id}".`
}
if (wordIds.size > 0 && !wordIds.has(blank.word_id)) {
return `Unknown word id "${blank.word_id}" for blank "${blank.blank_id}".`
}
const expectedText = wordTextById.get(blank.word_id)
if (expectedText !== undefined && blank.text !== expectedText) {
return `Answer text for blank "${blank.blank_id}" must match the selected word.`
}
}
if (stimulus && !stimulus.allow_reuse) {
const usedWordIds = filled.map((blank) => blank.word_id)
const unique = new Set(usedWordIds)
if (unique.size !== usedWordIds.length) {
return "Each word can only be used once when reuse is disabled."
}
}
return null
}
export function findSelectMissingWordsStimulusInFieldValues(
fieldValues: Record<string, string>,
stimulusSchema: { id: string; kind: string }[],
): SelectMissingWordsStimulusValue | null {
for (const row of stimulusSchema) {
if (row.kind.trim().toUpperCase() !== "SELECT_MISSING_WORDS") continue
const parsed = parseSelectMissingWordsStimulusSlotValue(
fieldValues[`stimulus:${row.id}`],
)
if (selectMissingWordsStimulusHasContent(parsed)) return parsed
}
return null
}

View File

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

View File

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

44
src/lib/teamInvitation.ts Normal file
View File

@ -0,0 +1,44 @@
import type { VerifyInvitationData } from "../types/teamInvitation.types"
export function formatTeamRoleLabel(role: string | undefined): string {
if (!role) return "—"
return role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export function formatInvitationExpiry(raw: string | undefined): string | null {
if (!raw) return null
const d = new Date(raw)
if (Number.isNaN(d.getTime())) return raw
return d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
}
/** User-facing title when verify returns valid: false. */
export function getInvalidInvitationTitle(data: VerifyInvitationData | null): string {
const status = data?.status?.toLowerCase() ?? ""
const message = (data?.message ?? "").toLowerCase()
if (status === "expired" || message.includes("expir")) {
return "This invitation has expired"
}
if (
status === "accepted" ||
message.includes("already") ||
message.includes("used") ||
message.includes("accepted")
) {
return "This invitation was already used"
}
if (status === "revoked" || message.includes("revok")) {
return "This invitation was revoked"
}
return "This invitation link is invalid"
}
export function getInvalidInvitationDescription(
data: VerifyInvitationData | null,
apiMessage?: string,
): string {
const specific = data?.message?.trim() || apiMessage?.trim()
if (specific) return specific
return "The link may be expired, invalid, or already used. Ask your administrator to send a new invitation."
}

43
src/lib/teamRoles.ts Normal file
View File

@ -0,0 +1,43 @@
import type { Role } from "../types/rbac.types"
export const TEAM_ROLE_OPTIONS = [
{ value: "SUPER_ADMIN", label: "Super Admin" },
{ value: "ADMIN", label: "Admin" },
{ value: "CONTENT_MANAGER", label: "Content Manager" },
{ value: "SUPPORT_AGENT", label: "Support Agent" },
{ value: "INSTRUCTOR", label: "Instructor" },
{ value: "FINANCE", label: "Finance" },
{ value: "HR", label: "HR" },
{ value: "ANALYST", label: "Analyst" },
] as const
export const EMPLOYMENT_TYPE_OPTIONS = [
{ value: "full_time", label: "Full-time" },
{ value: "part_time", label: "Part-time" },
{ value: "contractor", label: "Contractor" },
{ value: "intern", label: "Intern" },
] as const
/** Map RBAC role display name to API team_role (e.g. CONTENT_MANAGER). */
export function rbacRoleNameToTeamRole(roleName: string): string {
const normalized = roleName.trim().toUpperCase().replace(/[\s-]+/g, "_")
const byValue = TEAM_ROLE_OPTIONS.find((o) => o.value === normalized)
if (byValue) return byValue.value
const byLabel = TEAM_ROLE_OPTIONS.find(
(o) => o.label.toUpperCase().replace(/[\s-]+/g, "_") === normalized,
)
if (byLabel) return byLabel.value
return normalized
}
export function teamRoleFromRbacRole(role: Role): string {
return rbacRoleNameToTeamRole(role.name)
}
export function formatTeamRoleLabel(teamRole: string): string {
const found = TEAM_ROLE_OPTIONS.find(
(o) => o.value === teamRole || o.value === teamRole.toUpperCase(),
)
if (found) return found.label
return teamRole.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}

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

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

View File

@ -88,6 +88,19 @@ export function formatPreviewLength(totalSeconds: number): string {
return `${totalSeconds} seconds`; return `${totalSeconds} seconds`;
} }
/** Compact label for thumbnails (e.g. `3:02`, `1:05:07`). */
export function formatVideoDurationLabel(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "";
const s = Math.round(totalSeconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
return `${m}:${String(sec).padStart(2, "0")}`;
}
/** /**
* YouTube: `end` = stop after this many seconds from the start of the video. * YouTube: `end` = stop after this many seconds from the start of the video.
* Vimeo: time range in URL fragment (supported on many vimeo.com player links). * Vimeo: time range in URL fragment (supported on many vimeo.com player links).

View File

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

View File

@ -1,7 +1,7 @@
import { import {
// Activity, // Activity,
BadgeCheck, BadgeCheck,
BookOpen, Video,
// Coins, // Coins,
DollarSign, DollarSign,
HelpCircle, HelpCircle,
@ -10,15 +10,15 @@ import {
TicketCheck, TicketCheck,
// TrendingUp, // TrendingUp,
Users, Users,
UserX,
Bell, Bell,
CreditCard,
UsersRound, UsersRound,
} from "lucide-react" } from "lucide-react"
import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg" import spinnerSrc from "../assets/Circular-indeterminate progress indicator.svg"
import { import {
Area, Area,
AreaChart, AreaChart,
Bar,
BarChart,
CartesianGrid, CartesianGrid,
Cell, Cell,
Pie, Pie,
@ -28,15 +28,27 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts" } from "recharts"
import { RevenueTrendCard } from "../components/dashboard/RevenueTrendCard"
import { StatCard } from "../components/dashboard/StatCard" import { StatCard } from "../components/dashboard/StatCard"
import alertSrc from "../assets/Alert.svg" import alertSrc from "../assets/Alert.svg"
import { Badge } from "../components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
import { getTeamMemberById } from "../api/team.api" import { getTeamMemberById } from "../api/team.api"
import { getDashboard } from "../api/analytics.api" import { getDashboard } from "../api/analytics.api"
import { getSubscriptionPlans } from "../api/subscription-plans.api"
import { getRatings } from "../api/courses.api" import { getRatings } from "../api/courses.api"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import type { DashboardData } from "../types/analytics.types" import { AnalyticsTimeRangeFilter } from "../components/analytics/AnalyticsTimeRangeFilter"
import {
getPrimaryQuestionTypeSummary,
getSeriesPeriodLabel,
getSubscriptionMetrics,
getVideoLessonsSummary,
} from "../lib/analytics"
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
import { formatPlanDuration } from "../lib/subscriptionPlans"
import type { SubscriptionPlan } from "../types/subscription.types"
import type { Rating } from "../types/course.types" import type { Rating } from "../types/course.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"] const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444"]
@ -46,6 +58,8 @@ function formatDate(dateStr: string) {
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
} }
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
export function DashboardPage() { export function DashboardPage() {
const [userFirstName, setUserFirstName] = useState<string>("") const [userFirstName, setUserFirstName] = useState<string>("")
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
@ -53,6 +67,9 @@ export function DashboardPage() {
const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary") const [activeStatTab, setActiveStatTab] = useState<"primary" | "secondary">("primary")
const [appRatings, setAppRatings] = useState<Rating[]>([]) const [appRatings, setAppRatings] = useState<Rating[]>([])
const [appRatingsLoading, setAppRatingsLoading] = useState(true) const [appRatingsLoading, setAppRatingsLoading] = useState(true)
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
const [subscriptionPlans, setSubscriptionPlans] = useState<SubscriptionPlan[]>([])
const [subscriptionPlansLoading, setSubscriptionPlansLoading] = useState(true)
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
@ -70,17 +87,10 @@ export function DashboardPage() {
} }
} }
const fetchDashboard = async () => { fetchUser()
try { }, [])
const res = await getDashboard()
setDashboard(res.data as unknown as DashboardData)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
const fetchAppRatings = async () => { const fetchAppRatings = async () => {
try { try {
const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 }) const res = await getRatings({ target_type: "app", target_id: 1, limit: 5 })
@ -92,23 +102,49 @@ export function DashboardPage() {
} }
} }
fetchUser()
fetchDashboard()
fetchAppRatings() fetchAppRatings()
}, []) }, [])
useEffect(() => {
const fetchPlans = async () => {
setSubscriptionPlansLoading(true)
try {
const res = await getSubscriptionPlans()
setSubscriptionPlans(res.data)
} catch (err) {
console.error(err)
setSubscriptionPlans([])
} finally {
setSubscriptionPlansLoading(false)
}
}
fetchPlans()
}, [])
useEffect(() => {
const fetchDashboard = async () => {
setLoading(true)
try {
const res = await getDashboard(filters)
setDashboard(res.data)
} catch (err) {
console.error(err)
setDashboard(null)
} finally {
setLoading(false)
}
}
fetchDashboard()
}, [filters])
const registrationData = const registrationData =
dashboard?.users.registrations_last_30_days.map((d) => ({ dashboard?.users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date), date: formatDate(d.date),
count: d.count, count: d.count,
})) ?? [] })) ?? []
const revenueData =
dashboard?.payments.revenue_last_30_days.map((d) => ({
date: formatDate(d.date),
revenue: d.revenue,
})) ?? []
const subscriptionStatusData = const subscriptionStatusData =
dashboard?.subscriptions.by_status.map((s, i) => ({ dashboard?.subscriptions.by_status.map((s, i) => ({
name: s.label, name: s.label,
@ -123,9 +159,17 @@ export function DashboardPage() {
color: PIE_COLORS[i % PIE_COLORS.length], color: PIE_COLORS[i % PIE_COLORS.length],
})) ?? [] })) ?? []
const seriesPeriodLabel = dashboard ? getSeriesPeriodLabel(dashboard.date_filter) : "Last 30 Days"
const subscriptionMetrics = dashboard
? getSubscriptionMetrics(dashboard.subscriptions)
: null
return ( return (
<div className="mx-auto w-full max-w-6xl"> <div className="mx-auto w-full max-w-6xl">
<div className="mb-2 text-sm font-semibold text-grayScale-500">Dashboard</div> <div className="mb-2 flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold text-grayScale-500">Dashboard</div>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
</div>
<div className="mb-5 text-2xl font-semibold tracking-tight"> <div className="mb-5 text-2xl font-semibold tracking-tight">
Welcome, {userFirstName || localStorage.getItem("user_first_name")} Welcome, {userFirstName || localStorage.getItem("user_first_name")}
</div> </div>
@ -189,11 +233,11 @@ export function DashboardPage() {
deltaPositive={dashboard.users.new_month > 0} deltaPositive={dashboard.users.new_month > 0}
/> />
<StatCard <StatCard
icon={BadgeCheck} icon={CreditCard}
label="Active Subscribers" label="Payments"
value={dashboard.subscriptions.active_subscriptions.toLocaleString()} value={dashboard.payments.total_payments.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_month} this month`} deltaLabel={`${dashboard.payments.successful_payments} successful`}
deltaPositive={dashboard.subscriptions.new_month > 0} deltaPositive={dashboard.payments.successful_payments > 0}
/> />
<StatCard <StatCard
icon={DollarSign} icon={DollarSign}
@ -213,21 +257,49 @@ export function DashboardPage() {
)} )}
{/* Secondary Stats */} {/* Secondary Stats */}
{activeStatTab === "secondary" && ( {activeStatTab === "secondary" && subscriptionMetrics && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
icon={BookOpen} icon={CreditCard}
label="Courses" label="Total Subscriptions"
value={dashboard.courses.total_courses.toLocaleString()} value={subscriptionMetrics.total.toLocaleString()}
deltaLabel={`${dashboard.courses.total_sub_courses} sub-modules, ${dashboard.courses.total_videos} videos`} deltaLabel={`+${dashboard.subscriptions.new_month} this month`}
deltaPositive deltaPositive={dashboard.subscriptions.new_month > 0}
/>
<StatCard
icon={BadgeCheck}
label="Active Subscriptions"
value={subscriptionMetrics.active.toLocaleString()}
deltaLabel={`+${dashboard.subscriptions.new_today} today · +${dashboard.subscriptions.new_week} this week`}
deltaPositive={subscriptionMetrics.active > 0}
/>
<StatCard
icon={UserX}
label="Inactive Subscriptions"
value={subscriptionMetrics.inactive.toLocaleString()}
deltaLabel={
dashboard.subscriptions.by_status.length > 0
? "From subscription status breakdown"
: "Total minus active"
}
deltaPositive={subscriptionMetrics.inactive === 0}
/>
<StatCard
icon={Video}
label="Videos"
value={dashboard.courses.total_videos.toLocaleString()}
deltaLabel={getVideoLessonsSummary(
dashboard.courses.lms?.lessons_with_video,
dashboard.courses.exam_prep?.lessons_with_video,
)}
deltaPositive={dashboard.courses.total_videos > 0}
/> />
<StatCard <StatCard
icon={HelpCircle} icon={HelpCircle}
label="Questions" label="Questions"
value={dashboard.content.total_questions.toLocaleString()} value={dashboard.content.total_questions.toLocaleString()}
deltaLabel={`${dashboard.content.total_question_sets} question sets`} deltaLabel={getPrimaryQuestionTypeSummary(dashboard.content.questions_by_type)}
deltaPositive deltaPositive={dashboard.content.total_questions > 0}
/> />
<StatCard <StatCard
icon={Bell} icon={Bell}
@ -261,7 +333,7 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500"> <div className="rounded-full bg-grayScale-100 px-3 py-1 text-xs font-semibold text-grayScale-500">
Last 30 Days {seriesPeriodLabel}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@ -357,76 +429,69 @@ export function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Revenue Chart */} <RevenueTrendCard />
<Card className="shadow-none">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle>Revenue Trend</CardTitle>
<div className="mt-2 text-2xl font-semibold tracking-tight">
ETB {dashboard.payments.total_revenue.toLocaleString()}
</div>
<div className="text-xs font-medium text-grayScale-500">Last 30 Days (ETB)</div>
</div>
</div>
</CardHeader>
<CardContent className="h-[220px] p-6 pt-2">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={revenueData} margin={{ left: 8, right: 8, top: 8 }}>
<CartesianGrid vertical={false} stroke="#E0E0E0" strokeDasharray="4 4" />
<XAxis dataKey="date" tickLine={false} axisLine={false} fontSize={12} />
<YAxis tickLine={false} axisLine={false} fontSize={12} width={42} />
<Tooltip
formatter={(v) => [`${Number(v).toLocaleString()}`, "ETB"]}
contentStyle={{
borderRadius: 12,
border: "1px solid #E0E0E0",
boxShadow: "0 10px 30px rgba(0,0,0,0.08)",
}}
/>
<Bar dataKey="revenue" radius={[10, 10, 0, 0]} fill="#9E2891" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div> </div>
{/* Users by Role / Region / Knowledge Level */} {/* Subscription plans (from catalog API) */}
<div className="grid gap-4 lg:grid-cols-3"> <Card className="shadow-none">
{[ <CardHeader className="pb-2">
{ title: "Users by Role", data: dashboard.users.by_role }, <div className="flex items-center gap-2">
{ title: "Users by Region", data: dashboard.users.by_region }, <CreditCard className="h-5 w-5 text-brand-500" />
{ title: "Users by Knowledge Level", data: dashboard.users.by_knowledge_level }, <CardTitle>Subscription plans</CardTitle>
].map(({ title, data }) => ( </div>
<Card key={title} className="shadow-none"> <p className="text-sm text-grayScale-500">Available billing plans for learners.</p>
<CardHeader className="pb-2"> </CardHeader>
<CardTitle>{title}</CardTitle> <CardContent className="p-6 pt-2">
</CardHeader> {subscriptionPlansLoading ? (
<CardContent className="p-6 pt-2"> <div className="flex items-center justify-center py-10">
{data.length > 0 ? ( <img src={spinnerSrc} alt="" className="h-8 w-8 animate-spin" />
<div className="space-y-3"> </div>
{data.map((item, i) => ( ) : subscriptionPlans.length === 0 ? (
<div key={item.label} className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-center py-10 text-sm text-grayScale-400">
<div className="flex items-center gap-2"> No subscription plans found
<span </div>
className="h-2.5 w-2.5 rounded-full" ) : (
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }} <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
/> {subscriptionPlans.map((plan) => (
<span className="text-grayScale-600">{item.label}</span> <div
key={plan.id}
className="flex flex-col rounded-xl border border-grayScale-200 bg-grayScale-50/50 p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-grayScale-700">{plan.name}</h3>
<Badge variant={plan.is_active ? "success" : "secondary"}>
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</div>
{plan.description ? (
<p className="mt-2 line-clamp-2 text-sm text-grayScale-500">{plan.description}</p>
) : null}
<div className="mt-4 flex flex-wrap items-end justify-between gap-2 border-t border-grayScale-200 pt-4">
<div>
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">Price</div>
<div className="text-lg font-semibold text-brand-600">
{plan.currency}{" "}
{Number.isInteger(plan.price)
? plan.price.toLocaleString()
: plan.price.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})}
</div> </div>
<span className="font-semibold text-grayScale-600">{item.count.toLocaleString()}</span>
</div> </div>
))} <div className="text-right">
<div className="text-xs font-medium uppercase tracking-wide text-grayScale-400">
Billing
</div>
<div className="text-sm font-semibold text-grayScale-600">{formatPlanDuration(plan)}</div>
</div>
</div>
</div> </div>
) : ( ))}
<div className="flex items-center justify-center py-6 text-sm text-grayScale-400"> </div>
No data available )}
</div> </CardContent>
)} </Card>
</CardContent>
</Card>
))}
</div>
{/* App Ratings */} {/* App Ratings */}
<Card className="shadow-none"> <Card className="shadow-none">

View File

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

View File

@ -39,7 +39,15 @@ import { Badge } from "../../components/ui/badge"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { getDashboard } from "../../api/analytics.api" import { getDashboard } from "../../api/analytics.api"
import type { DashboardData, LabelCount } from "../../types/analytics.types" import { AnalyticsTimeRangeFilter, getDashboardFilterLabel } from "../../components/analytics/AnalyticsTimeRangeFilter"
import {
getPrimaryQuestionTypeSummary,
getSeriesPeriodLabel,
formatAnalyticsLabel,
getSubscriptionMetrics,
getVideoLessonsSummary,
} from "../../lib/analytics"
import type { DashboardData, DashboardFilters, LabelCount } from "../../types/analytics.types"
const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"] const PIE_COLORS = ["#9E2891", "#FFD23F", "#1DE9B6", "#C26FC0", "#6366F1", "#F97316", "#14B8A6", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
@ -109,31 +117,43 @@ function BreakdownList({
title, title,
data, data,
total, total,
scrollable,
}: { }: {
title: string title: string
data: LabelCount[] data: LabelCount[]
total?: number total?: number
/** Enable vertical scroll for long breakdowns (e.g. occupation). */
scrollable?: boolean
}) { }) {
const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0) const computedTotal = total ?? data.reduce((s, d) => s + d.count, 0)
const sorted = [...data].sort((a, b) => b.count - a.count)
return ( return (
<Card className="shadow-none"> <Card className="shadow-none">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm">{title}</CardTitle> <CardTitle className="text-sm">{title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4 pt-0"> <CardContent className="p-4 pt-0">
{data.length > 0 ? ( {sorted.length > 0 ? (
<div className="space-y-2.5"> <div
{data.map((item, i) => { className={cn(
"space-y-2.5",
scrollable && "max-h-64 overflow-y-auto overscroll-contain pr-1",
)}
>
{sorted.map((item, i) => {
const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0 const pct = computedTotal > 0 ? (item.count / computedTotal) * 100 : 0
const displayLabel = formatAnalyticsLabel(item.label)
return ( return (
<div key={item.label}> <div key={`${item.label}-${i}`}>
<div className="mb-1 flex items-center justify-between text-xs"> <div className="mb-1 flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span <span
className="h-2 w-2 rounded-full" className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }} style={{ backgroundColor: PIE_COLORS[i % PIE_COLORS.length] }}
/> />
<span className="text-grayScale-600">{item.label}</span> <span className="truncate text-grayScale-600" title={displayLabel}>
{displayLabel}
</span>
</div> </div>
<span className="font-semibold text-grayScale-700"> <span className="font-semibold text-grayScale-700">
{item.count.toLocaleString()} {item.count.toLocaleString()}
@ -285,18 +305,21 @@ function Section({
) )
} }
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
export function AnalyticsPage() { export function AnalyticsPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null) const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(false) const [error, setError] = useState(false)
const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key") const [activeSummaryTab, setActiveSummaryTab] = useState<"key" | "content" | "operations">("key")
const [filters, setFilters] = useState<DashboardFilters>(DEFAULT_FILTERS)
const fetchData = async () => { const fetchData = async (nextFilters: DashboardFilters = filters) => {
setLoading(true) setLoading(true)
setError(false) setError(false)
try { try {
const res = await getDashboard() const res = await getDashboard(nextFilters)
setDashboard(res.data as unknown as DashboardData) setDashboard(res.data)
} catch { } catch {
setError(true) setError(true)
} finally { } finally {
@ -305,10 +328,11 @@ export function AnalyticsPage() {
} }
useEffect(() => { useEffect(() => {
fetchData() fetchData(filters)
}, []) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters])
if (loading) { if (!dashboard && loading) {
return ( return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4"> <div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
@ -323,11 +347,14 @@ export function AnalyticsPage() {
if (error || !dashboard) { if (error || !dashboard) {
return ( return (
<div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4"> <div className="mx-auto w-full max-w-[1280px] px-2 sm:px-4">
<div className="mb-6 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
</div>
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24"> <div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-red-100 bg-red-50/30 py-24">
<img src={alertSrc} alt="" className="h-12 w-12" /> <img src={alertSrc} alt="" className="h-12 w-12" />
<span className="text-sm text-destructive">Failed to load analytics data.</span> <span className="text-sm text-destructive">Failed to load analytics data.</span>
<Button variant="outline" size="sm" onClick={fetchData}> <Button variant="outline" size="sm" onClick={() => fetchData(filters)}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Retry Retry
</Button> </Button>
@ -337,6 +364,10 @@ export function AnalyticsPage() {
} }
const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard const { users, subscriptions, payments, courses, content, notifications, issues, team } = dashboard
const subscriptionMetrics = getSubscriptionMetrics(subscriptions)
const seriesPeriodLabel = getSeriesPeriodLabel(dashboard.date_filter)
const lms = courses.lms
const examPrep = courses.exam_prep
const registrationData = users.registrations_last_30_days.map((d) => ({ const registrationData = users.registrations_last_30_days.map((d) => ({
date: formatDate(d.date), date: formatDate(d.date),
@ -387,15 +418,25 @@ export function AnalyticsPage() {
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div> <div className="mb-1 text-xs font-semibold uppercase tracking-wide text-grayScale-400">Analytics</div>
<h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1> <h1 className="text-3xl font-semibold tracking-tight text-grayScale-900">Platform Overview</h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<span className="text-xs text-grayScale-400">Generated {generatedAt}</span> <span className="text-xs text-grayScale-400">
<Button variant="outline" size="sm" onClick={fetchData}> {getDashboardFilterLabel(filters)} · Generated {generatedAt}
<RefreshCw className="mr-2 h-3.5 w-3.5" /> </span>
<AnalyticsTimeRangeFilter value={filters} onChange={setFilters} />
<Button variant="outline" size="sm" onClick={() => fetchData(filters)} disabled={loading}>
<RefreshCw className={cn("mr-2 h-3.5 w-3.5", loading && "animate-spin")} />
Refresh Refresh
</Button> </Button>
</div> </div>
</div> </div>
{loading && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-grayScale-100 bg-grayScale-50 px-3 py-2 text-xs text-grayScale-500">
<img src={spinnerSrc} alt="" className="h-4 w-4 animate-spin" />
Updating analytics for {getDashboardFilterLabel(filters)}
</div>
)}
{/* Summary Tabs */} {/* Summary Tabs */}
<div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm"> <div className="mb-6 rounded-2xl border border-grayScale-100 bg-white px-5 pt-4 shadow-sm">
<div className="-mb-px flex gap-6"> <div className="-mb-px flex gap-6">
@ -452,10 +493,10 @@ export function AnalyticsPage() {
trend={users.new_month > 0 ? "up" : "neutral"} trend={users.new_month > 0 ? "up" : "neutral"}
/> />
<KpiCard <KpiCard
icon={BadgeCheck} icon={CreditCard}
label="Active Subscriptions" label="Total Subscriptions"
value={formatNumber(subscriptions.active_subscriptions)} value={formatNumber(subscriptionMetrics.total)}
sub={`${subscriptions.total_subscriptions} total · +${subscriptions.new_month} this month`} sub={`${subscriptionMetrics.active} active · ${subscriptionMetrics.inactive} inactive`}
trend={subscriptions.new_month > 0 ? "up" : "neutral"} trend={subscriptions.new_month > 0 ? "up" : "neutral"}
/> />
<KpiCard <KpiCard
@ -483,7 +524,7 @@ export function AnalyticsPage() {
<Section <Section
title="Content & Platform" title="Content & Platform"
icon={BookOpen} icon={BookOpen}
count={courses.total_courses + content.total_questions} count={courses.total_videos + content.total_questions}
defaultOpen defaultOpen
> >
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@ -491,28 +532,29 @@ export function AnalyticsPage() {
icon={FolderOpen} icon={FolderOpen}
label="Categories" label="Categories"
value={courses.total_categories.toLocaleString()} value={courses.total_categories.toLocaleString()}
sub={`${courses.total_courses} courses`} sub={`${courses.total_courses} courses · ${courses.total_sub_courses} modules`}
trend="neutral" trend="neutral"
/> />
<KpiCard <KpiCard
icon={BookOpen} icon={BookOpen}
label="Sub-Courses" label="LMS Programs"
value={courses.total_sub_courses.toLocaleString()} value={(lms?.programs ?? 0).toLocaleString()}
sub={`across ${courses.total_courses} courses`} sub={`${lms?.courses ?? 0} courses · ${lms?.practices ?? 0} practices`}
trend="neutral" trend="neutral"
/> />
<KpiCard <KpiCard
icon={Video} icon={Video}
label="Videos" label="Videos"
value={courses.total_videos.toLocaleString()} value={courses.total_videos.toLocaleString()}
trend="neutral" sub={getVideoLessonsSummary(lms?.lessons_with_video, examPrep?.lessons_with_video)}
trend={courses.total_videos > 0 ? "up" : "neutral"}
/> />
<KpiCard <KpiCard
icon={HelpCircle} icon={HelpCircle}
label="Questions" label="Questions"
value={content.total_questions.toLocaleString()} value={content.total_questions.toLocaleString()}
sub={`${content.total_question_sets} question sets`} sub={getPrimaryQuestionTypeSummary(content.questions_by_type)}
trend="neutral" trend={content.total_questions > 0 ? "up" : "neutral"}
/> />
</div> </div>
</Section> </Section>
@ -573,7 +615,7 @@ export function AnalyticsPage() {
</Badge> </Badge>
</div> </div>
</div> </div>
<Badge variant="secondary">Last 30 Days</Badge> <Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-[280px] p-6 pt-2"> <CardContent className="h-[280px] p-6 pt-2">
@ -601,12 +643,77 @@ export function AnalyticsPage() {
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>
</Card> </Card>
<div className="mt-4 grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="mt-4 space-y-6">
<BreakdownList title="Users by Role" data={users.by_role} total={users.total_users} /> <div>
<BreakdownList title="Users by Status" data={users.by_status} total={users.total_users} /> <p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
<BreakdownList title="Users by Age Group" data={users.by_age_group} total={users.total_users} /> Profile & demographics
<BreakdownList title="Users by Knowledge Level" data={users.by_knowledge_level} total={users.total_users} /> </p>
<BreakdownList title="Users by Region" data={users.by_region} total={users.total_users} /> <div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList
title="Education level"
data={users.by_education_level ?? []}
total={users.total_users}
/>
<BreakdownList
title="Occupation"
data={users.by_occupation ?? []}
total={users.total_users}
scrollable
/>
<BreakdownList
title="Age group"
data={users.by_age_group ?? []}
total={users.total_users}
/>
<BreakdownList
title="Country"
data={users.by_country ?? []}
total={users.total_users}
/>
<BreakdownList
title="Region"
data={users.by_region ?? []}
total={users.total_users}
scrollable
/>
<BreakdownList
title="Account role"
data={users.by_role ?? []}
total={users.total_users}
/>
<BreakdownList
title="Account status"
data={users.by_status ?? []}
total={users.total_users}
/>
{(users.by_knowledge_level?.length ?? 0) > 0 ? (
<BreakdownList
title="Knowledge level"
data={users.by_knowledge_level ?? []}
total={users.total_users}
/>
) : null}
</div>
</div>
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Learning goals & challenges
</p>
<div className="grid items-start gap-4 sm:grid-cols-2 lg:grid-cols-3">
<BreakdownList
title="Learning goal"
data={users.by_learning_goal ?? []}
total={users.total_users}
scrollable
/>
<BreakdownList
title="Language challenge"
data={users.by_language_challange ?? []}
total={users.total_users}
scrollable
/>
</div>
</div>
</div> </div>
</Section> </Section>
@ -625,7 +732,7 @@ export function AnalyticsPage() {
+{subscriptions.new_today} today · +{subscriptions.new_week} this week +{subscriptions.new_today} today · +{subscriptions.new_week} this week
</div> </div>
</div> </div>
<Badge variant="secondary">Last 30 Days</Badge> <Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-[240px] p-6 pt-2"> <CardContent className="h-[240px] p-6 pt-2">
@ -664,7 +771,7 @@ export function AnalyticsPage() {
</div> </div>
<div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div> <div className="text-xs text-grayScale-400">Daily revenue over last 30 days</div>
</div> </div>
<Badge variant="secondary">Last 30 Days</Badge> <Badge variant="secondary">{seriesPeriodLabel}</Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="h-[240px] p-6 pt-2"> <CardContent className="h-[240px] p-6 pt-2">
@ -728,6 +835,43 @@ export function AnalyticsPage() {
</div> </div>
</Section> </Section>
{/* ─── Course Management ─── */}
{(lms || examPrep) && (
<Section title="Course Management" icon={BookOpen} count={courses.total_videos} defaultOpen={false}>
<div className="grid items-start gap-4 lg:grid-cols-2">
{lms && (
<BreakdownList
title="LMS"
data={[
{ label: "Programs", count: lms.programs },
{ label: "Courses", count: lms.courses },
{ label: "Modules", count: lms.modules },
{ label: "Lessons", count: lms.lessons },
{ label: "Lessons with video", count: lms.lessons_with_video },
{ label: "Practices", count: lms.practices },
{ label: "Practices at course", count: lms.practices_at_course },
{ label: "Practices at module", count: lms.practices_at_module },
{ label: "Practices at lesson", count: lms.practices_at_lesson },
]}
/>
)}
{examPrep && (
<BreakdownList
title="Exam prep"
data={[
{ label: "Catalog courses", count: examPrep.catalog_courses },
{ label: "Units", count: examPrep.units },
{ label: "Unit modules", count: examPrep.unit_modules },
{ label: "Lessons", count: examPrep.lessons },
{ label: "Lessons with video", count: examPrep.lessons_with_video },
{ label: "Lesson practices", count: examPrep.lesson_practices },
]}
/>
)}
</div>
</Section>
)}
{/* ─── Content Breakdown ─── */} {/* ─── Content Breakdown ─── */}
<Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}> <Section title="Content Breakdown" icon={HelpCircle} count={content.total_questions} defaultOpen={false}>
<div className="grid items-start gap-4 sm:grid-cols-2"> <div className="grid items-start gap-4 sm:grid-cols-2">

View File

@ -0,0 +1,467 @@
import { useCallback, useEffect, useState } from "react"
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"
import {
AlertCircle,
Briefcase,
Building2,
Eye,
EyeOff,
Mail,
Phone,
Shield,
User,
} from "lucide-react"
import { toast } from "sonner"
import {
acceptTeamInvitation,
parseVerifyInvitation,
verifyTeamInvitation,
} from "../../api/team.api"
import { BrandLogo } from "../../components/brand/BrandLogo"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import {
formatInvitationExpiry,
formatTeamRoleLabel,
getInvalidInvitationDescription,
getInvalidInvitationTitle,
} from "../../lib/teamInvitation"
import type { VerifyInvitationData } from "../../types/teamInvitation.types"
export function AcceptInvitePage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get("token")?.trim() ?? ""
const [verifyState, setVerifyState] = useState<
"loading" | "invalid" | "ready" | "success"
>("loading")
const [inviteInfo, setInviteInfo] = useState<VerifyInvitationData | null>(null)
const [invalidTitle, setInvalidTitle] = useState("")
const [invalidDescription, setInvalidDescription] = useState("")
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [phoneNumber, setPhoneNumber] = useState("")
const [department, setDepartment] = useState("")
const [jobTitle, setJobTitle] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false)
const loadVerification = useCallback(async () => {
if (!token) {
setInviteInfo(null)
setInvalidTitle("This invitation link is invalid")
setInvalidDescription("Invitation link is missing a token.")
setVerifyState("invalid")
return
}
setVerifyState("loading")
setInviteInfo(null)
try {
const res = await verifyTeamInvitation(token)
const data = parseVerifyInvitation(res)
if (!data || data.valid !== true) {
setInviteInfo(data)
setInvalidTitle(getInvalidInvitationTitle(data))
setInvalidDescription(
getInvalidInvitationDescription(data, res.data?.message),
)
setVerifyState("invalid")
return
}
setInviteInfo(data)
setFirstName(data.first_name?.trim() ?? "")
setLastName(data.last_name?.trim() ?? "")
setVerifyState("ready")
} catch (e: unknown) {
setInviteInfo(null)
setInvalidTitle("This invitation link is invalid")
setInvalidDescription(
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
"The link may be expired, invalid, or already used. Ask your administrator to send a new invitation.",
)
setVerifyState("invalid")
}
}, [token])
useEffect(() => {
void loadVerification()
}, [loadVerification])
const handleAccept = async (e: React.FormEvent) => {
e.preventDefault()
if (!token) return
if (!firstName.trim() || !lastName.trim()) {
toast.error("First name and last name are required")
return
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters")
return
}
if (password !== confirmPassword) {
toast.error("Passwords do not match")
return
}
setSubmitting(true)
try {
const res = await acceptTeamInvitation({
token,
password,
first_name: firstName.trim(),
last_name: lastName.trim(),
phone_number: phoneNumber.trim(),
department: department.trim(),
job_title: jobTitle.trim(),
})
setVerifyState("success")
toast.success(res.data?.message ?? "Account setup complete. You can sign in now.")
navigate("/login", { replace: true })
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to complete setup"
toast.error(msg)
} finally {
setSubmitting(false)
}
}
const expiryLabel = formatInvitationExpiry(inviteInfo?.expires_at)
const setupTitle = inviteInfo?.needs_profile_setup
? "Complete your account setup"
: "Set your password"
if (localStorage.getItem("access_token")) {
return <Navigate to="/dashboard" replace />
}
return (
<div className="relative flex min-h-screen overflow-hidden">
<div className="relative hidden items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 lg:flex lg:w-1/2 xl:w-[55%]">
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
</div>
<div className="relative z-10 max-w-md px-12 text-center">
<BrandLogo variant="light" className="mx-auto mb-8 h-16" />
<p className="text-base leading-relaxed text-white/70">
You have been invited to join the Yimaru admin panel. Verify your invitation,
then complete setup to activate your account.
</p>
</div>
</div>
<div className="flex h-screen min-h-0 w-full flex-col overflow-hidden bg-white px-6 py-6 lg:w-1/2 lg:py-8 xl:w-[45%]">
<div className="mx-auto flex h-full min-h-0 w-full max-w-[440px] flex-col">
<div className="shrink-0">
<div className="mb-6 flex justify-center lg:hidden">
<BrandLogo />
</div>
<div className="mb-4 lg:mb-6">
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
Team invitation
</p>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-grayScale-600 sm:text-3xl">
{verifyState === "success"
? "You're all set"
: verifyState === "invalid"
? invalidTitle
: "Accept invitation"}
</h1>
<p className="text-sm leading-relaxed text-grayScale-400">
{verifyState === "success"
? "Redirecting you to sign in…"
: verifyState === "invalid"
? invalidDescription
: verifyState === "ready"
? setupTitle
: "Verifying your invitation link…"}
</p>
</div>
</div>
<div
className={cn(
"min-h-0 flex-1",
verifyState === "ready" ? "overflow-y-auto overscroll-contain" : "flex flex-col justify-center",
)}
>
{verifyState === "loading" && (
<div className="flex flex-col items-center gap-3 py-16">
<SpinnerIcon className="h-8 w-8" />
<p className="text-sm text-grayScale-400">Verifying invitation</p>
</div>
)}
{verifyState === "invalid" && (
<div className="space-y-4 rounded-xl border border-red-200 bg-red-50 px-4 py-4">
<div className="flex gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" />
<div className="space-y-2 text-sm text-red-800">
<p className="font-semibold">{invalidTitle}</p>
<p>{invalidDescription}</p>
<p className="text-xs text-red-700/90">
Common reasons: expired, invalid, or already used.
</p>
</div>
</div>
{inviteInfo?.email ? (
<p className="border-t border-red-200/80 pt-3 text-xs text-red-700/80">
Invitation email: <span className="font-medium">{inviteInfo.email}</span>
</p>
) : null}
<Button
variant="outline"
size="sm"
className="border-red-200 bg-white"
onClick={() => void loadVerification()}
>
Try again
</Button>
</div>
)}
{verifyState === "ready" && inviteInfo && (
<form
onSubmit={(e) => void handleAccept(e)}
className="space-y-5 pr-1 pb-4"
>
<div>
<label
htmlFor="invite-email"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Mail className="h-3.5 w-3.5" />
Email
</label>
<Input
id="invite-email"
type="email"
readOnly
value={inviteInfo.email ?? ""}
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
/>
</div>
<div>
<label
htmlFor="invite-role"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Shield className="h-3.5 w-3.5" />
Role
</label>
<Input
id="invite-role"
readOnly
value={formatTeamRoleLabel(inviteInfo.team_role)}
className="cursor-not-allowed bg-grayScale-50 text-grayScale-700"
/>
</div>
{expiryLabel ? (
<p className="text-xs text-grayScale-400">
Invitation expires {expiryLabel}
{inviteInfo.status ? ` · Status: ${inviteInfo.status}` : null}
</p>
) : null}
<div className="border-t border-grayScale-100 pt-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Your details
</p>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="first-name"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<User className="h-3.5 w-3.5" />
First name
</label>
<Input
id="first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="John"
autoComplete="given-name"
disabled={submitting}
required
/>
</div>
<div>
<label
htmlFor="last-name"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<User className="h-3.5 w-3.5" />
Last name
</label>
<Input
id="last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
autoComplete="family-name"
disabled={submitting}
required
/>
</div>
</div>
<div>
<label
htmlFor="phone"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Phone className="h-3.5 w-3.5" />
Phone number
<span className="font-normal text-grayScale-400">(optional)</span>
</label>
<Input
id="phone"
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+251..."
autoComplete="tel"
disabled={submitting}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="department"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Building2 className="h-3.5 w-3.5" />
Department
<span className="font-normal text-grayScale-400">(optional)</span>
</label>
<Input
id="department"
value={department}
onChange={(e) => setDepartment(e.target.value)}
placeholder="e.g. LMS"
disabled={submitting}
/>
</div>
<div>
<label
htmlFor="job-title"
className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-grayScale-600"
>
<Briefcase className="h-3.5 w-3.5" />
Job title
<span className="font-normal text-grayScale-400">(optional)</span>
</label>
<Input
id="job-title"
value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)}
placeholder="e.g. Content Lead"
disabled={submitting}
/>
</div>
</div>
</div>
</div>
<div className="border-t border-grayScale-100 pt-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wide text-grayScale-400">
Account password
</p>
<div className="space-y-4">
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-grayScale-600"
>
Password
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 8 characters"
className="pr-10"
disabled={submitting}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<label
htmlFor="confirmPassword"
className="mb-1.5 block text-sm font-medium text-grayScale-600"
>
Confirm password
</label>
<Input
id="confirmPassword"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={submitting}
/>
</div>
</div>
</div>
<Button
type="submit"
className="h-11 w-full bg-brand-500 text-white hover:bg-brand-600"
disabled={submitting}
>
{submitting ? "Completing setup…" : "Complete account setup"}
</Button>
</form>
)}
{verifyState === "success" && (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<SpinnerIcon className="h-6 w-6" />
<p className="text-sm text-grayScale-500">Taking you to sign in</p>
</div>
)}
</div>
<p className="shrink-0 border-t border-grayScale-100 pt-4 text-center text-sm text-grayScale-400">
Already have an account?{" "}
<Link to="/login" className="font-semibold text-brand-500 hover:text-brand-600">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}

View File

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

View File

@ -5,6 +5,8 @@ import { toast } from "sonner"
import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api" import { addQuestionToSet, createLesson, createQuestion } from "../../api/courses.api"
import { uploadVideoFile } from "../../api/files.api" import { uploadVideoFile } from "../../api/files.api"
import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields" import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"
import type { PracticeQuestionDynamicRow } from "../../components/content-management/PracticeQuestionEditorFields"
import { buildDynamicQuestionPayload } from "../../lib/practiceDynamicQuestionPayload"
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"
@ -12,7 +14,7 @@ import { SpinnerIcon } from "../../components/ui/spinner-icon"
import type { QuestionOption } from "../../types/course.types" import type { QuestionOption } from "../../types/course.types"
type Step = 1 | 2 | 3 | 4 type Step = 1 | 2 | 3 | 4
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC"
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD" type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"
type ResultStatus = "success" | "error" type ResultStatus = "success" | "error"
@ -35,6 +37,10 @@ interface Question {
audioCorrectAnswerText: string audioCorrectAnswerText: string
shortAnswers: string[] shortAnswers: string[]
imageUrl: string imageUrl: string
questionTypeDefinitionId: number | null
dynamicStimulusRows: PracticeQuestionDynamicRow[]
dynamicResponseRows: PracticeQuestionDynamicRow[]
dynamicFieldValues: Record<string, string>
} }
const STEPS = [ const STEPS = [
@ -63,6 +69,10 @@ function createEmptyQuestion(id: string): Question {
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
shortAnswers: [], shortAnswers: [],
imageUrl: "", imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
} }
} }
@ -104,6 +114,7 @@ function questionTypeLabel(type: QuestionType): string {
if (type === "TRUE_FALSE") return "True/False" if (type === "TRUE_FALSE") return "True/False"
if (type === "SHORT") return "Short Answer" if (type === "SHORT") return "Short Answer"
if (type === "AUDIO") return "Audio" if (type === "AUDIO") return "Audio"
if (type === "DYNAMIC") return "Dynamic"
return "Multiple Choice" return "Multiple Choice"
} }
@ -224,6 +235,39 @@ export function AddNewLessonPage() {
for (let i = 0; i < questions.length; i++) { for (let i = 0; i < questions.length; i++) {
const q = questions[i] const q = questions[i]
if (!q.questionText.trim()) continue if (!q.questionText.trim()) continue
if (q.questionType === "DYNAMIC") {
if (q.questionTypeDefinitionId == null || q.questionTypeDefinitionId <= 0) {
toast.error(`Question ${i + 1}: select a question type definition for dynamic questions.`)
setSaving(false)
return
}
const missingStimulus = q.dynamicStimulusRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`stimulus:${row.id}`]?.trim()),
)
if (missingStimulus) {
toast.error(
`Question ${i + 1}: fill required stimulus "${missingStimulus.label || missingStimulus.id}".`,
)
setSaving(false)
return
}
const missingResponse = q.dynamicResponseRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`response:${row.id}`]?.trim()),
)
if (missingResponse) {
toast.error(
`Question ${i + 1}: fill required response "${missingResponse.label || missingResponse.id}".`,
)
setSaving(false)
return
}
}
const options: QuestionOption[] = const options: QuestionOption[] =
q.questionType === "MCQ" q.questionType === "MCQ"
? q.options.map((opt, idx) => ({ ? q.options.map((opt, idx) => ({
@ -233,6 +277,15 @@ export function AddNewLessonPage() {
})) }))
: [] : []
const dynamicPayload =
q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
? buildDynamicQuestionPayload({
stimulusRows: q.dynamicStimulusRows,
responseRows: q.dynamicResponseRows,
fieldValues: q.dynamicFieldValues,
})
: undefined
const qRes = await createQuestion({ const qRes = await createQuestion({
question_text: q.questionText, question_text: q.questionText,
question_type: q.questionType, question_type: q.questionType,
@ -240,13 +293,22 @@ export function AddNewLessonPage() {
points: q.points, points: q.points,
tips: q.tips || undefined, tips: q.tips || undefined,
explanation: q.explanation || undefined, explanation: q.explanation || undefined,
status: "PUBLISHED", status,
options: options.length > 0 ? options : undefined, options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined, voice_prompt: q.questionType === "DYNAMIC" ? undefined : q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined, sample_answer_voice_prompt:
audio_correct_answer_text: q.audioCorrectAnswerText || undefined, q.questionType === "DYNAMIC" ? undefined : q.sampleAnswerVoicePrompt || undefined,
image_url: q.imageUrl.trim() || undefined, audio_correct_answer_text:
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined, q.questionType === "DYNAMIC" ? undefined : q.audioCorrectAnswerText || undefined,
image_url: q.questionType === "DYNAMIC" ? undefined : q.imageUrl.trim() || undefined,
short_answers:
q.questionType !== "DYNAMIC" && q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
...(q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
? {
question_type_definition_id: q.questionTypeDefinitionId,
dynamic_payload: dynamicPayload,
}
: {}),
}) })
const questionId = qRes.data?.data?.id const questionId = qRes.data?.data?.id
if (questionId) { if (questionId) {
@ -457,6 +519,10 @@ export function AddNewLessonPage() {
audioCorrectAnswerText: question.audioCorrectAnswerText, audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "", shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl, imageUrl: question.imageUrl,
questionTypeDefinitionId: question.questionTypeDefinitionId,
dynamicStimulusRows: question.dynamicStimulusRows,
dynamicResponseRows: question.dynamicResponseRows,
dynamicFieldValues: question.dynamicFieldValues,
}} }}
onChange={(next) => onChange={(next) =>
updateQuestion(question.id, { updateQuestion(question.id, {
@ -472,6 +538,10 @@ export function AddNewLessonPage() {
audioCorrectAnswerText: next.audioCorrectAnswerText, audioCorrectAnswerText: next.audioCorrectAnswerText,
shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [], shortAnswers: next.shortAnswer.trim() ? [next.shortAnswer.trim()] : [],
imageUrl: next.imageUrl, imageUrl: next.imageUrl,
questionTypeDefinitionId: next.questionTypeDefinitionId,
dynamicStimulusRows: next.dynamicStimulusRows,
dynamicResponseRows: next.dynamicResponseRows,
dynamicFieldValues: next.dynamicFieldValues,
}) })
} }
mediaBusy={saving} mediaBusy={saving}

View File

@ -9,8 +9,6 @@ import {
Plus, Plus,
Trash2, Trash2,
GripVertical, GripVertical,
Edit,
Rocket,
Loader2, Loader2,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
@ -19,26 +17,24 @@ import { Card } 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 { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields"; import { PracticeQuestionEditorFields } from "../../components/content-management/PracticeQuestionEditorFields";
import { AddNewPracticeReviewStep } from "./components/AddNewPracticeReviewStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { useActivePersonas } from "../../hooks/useActivePersonas";
import { import {
createQuestionSet, createQuestionSet,
createQuestion, createQuestion,
addQuestionToSet, addQuestionToSet,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadVideoFile } from "../../api/files.api"; import { uploadVideoFile } from "../../api/files.api";
import { Select } from "../../components/ui/select";
import type { QuestionOption } from "../../types/course.types"; import type { QuestionOption } from "../../types/course.types";
import type { PracticeQuestionDynamicRow } from "../../components/content-management/PracticeQuestionEditorFields";
import { buildDynamicQuestionPayload } from "../../lib/practiceDynamicQuestionPayload";
type Step = 1 | 2 | 3 | 4 | 5; type Step = 1 | 2 | 3 | 4 | 5;
type ResultStatus = "success" | "error"; type ResultStatus = "success" | "error";
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO"; type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT" | "AUDIO" | "DYNAMIC";
type DifficultyLevel = "EASY" | "MEDIUM" | "HARD"; type DifficultyLevel = "EASY" | "MEDIUM" | "HARD";
interface Persona {
id: string;
name: string;
avatar: string;
}
interface MCQOption { interface MCQOption {
text: string; text: string;
isCorrect: boolean; isCorrect: boolean;
@ -58,51 +54,12 @@ interface Question {
audioCorrectAnswerText: string; audioCorrectAnswerText: string;
shortAnswers: string[]; shortAnswers: string[];
imageUrl: string; imageUrl: string;
questionTypeDefinitionId: number | null;
dynamicStimulusRows: PracticeQuestionDynamicRow[];
dynamicResponseRows: PracticeQuestionDynamicRow[];
dynamicFieldValues: Record<string, string>;
} }
const PERSONAS: Persona[] = [
{
id: "1",
name: "Dawit",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Dawit",
},
{
id: "2",
name: "Mahlet",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Mahlet",
},
{
id: "3",
name: "Amanuel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Amanuel",
},
{
id: "4",
name: "Bethel",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Bethel",
},
{
id: "5",
name: "Liya",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Liya",
},
{
id: "6",
name: "Aseffa",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Aseffa",
},
{
id: "7",
name: "Hana",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Hana",
},
{
id: "8",
name: "Nahom",
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Nahom",
},
];
const STEPS = [ const STEPS = [
{ number: 1, label: "Context" }, { number: 1, label: "Context" },
{ number: 2, label: "Persona" }, { number: 2, label: "Persona" },
@ -153,64 +110,6 @@ function isDirectVideoFile(url: string): boolean {
return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean); return /\.(mp4|webm|ogg|mov|m4v)$/.test(clean);
} }
function escapeHtml(raw: string): string {
return raw
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function sanitizeAdminRichTextHtml(input: string): string {
if (!input.trim()) return "";
try {
const parser = new DOMParser();
const doc = parser.parseFromString(input, "text/html");
const blockedTags = new Set([
"script",
"style",
"iframe",
"object",
"embed",
"link",
"meta",
]);
doc.body.querySelectorAll("*").forEach((el) => {
const tagName = el.tagName.toLowerCase();
if (blockedTags.has(tagName)) {
el.remove();
return;
}
const attrs = [...el.attributes];
attrs.forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value.trim().toLowerCase();
if (name.startsWith("on")) {
el.removeAttribute(attr.name);
return;
}
if (
(name === "href" || name === "src") &&
value.startsWith("javascript:")
) {
el.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
} catch {
return escapeHtml(input).replace(/\r?\n/g, "<br />");
}
}
function formatDescriptionForPreview(raw: string): string {
if (!raw.trim()) return "";
const hasHtml = /<\/?[a-z][\s\S]*>/i.test(raw);
if (hasHtml) return sanitizeAdminRichTextHtml(raw);
return escapeHtml(raw).replace(/\r?\n/g, "<br />");
}
function createEmptyQuestion(id: string): Question { function createEmptyQuestion(id: string): Question {
return { return {
id, id,
@ -231,6 +130,10 @@ function createEmptyQuestion(id: string): Question {
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
shortAnswers: [], shortAnswers: [],
imageUrl: "", imageUrl: "",
questionTypeDefinitionId: null,
dynamicStimulusRows: [],
dynamicResponseRows: [],
dynamicFieldValues: {},
}; };
} }
@ -272,6 +175,12 @@ export function AddNewPracticePage() {
// Step 2: Persona // Step 2: Persona
const [selectedPersona, setSelectedPersona] = useState<string | null>(null); const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const {
personas,
loading: personasLoading,
error: personasError,
reload: reloadPersonas,
} = useActivePersonas();
// Step 3: Questions // Step 3: Questions
const [questions, setQuestions] = useState<Question[]>([ const [questions, setQuestions] = useState<Question[]>([
@ -364,11 +273,6 @@ export function AddNewPracticePage() {
return null; return null;
}, [introVideoUrl]); }, [introVideoUrl]);
const descriptionPreviewHtml = useMemo(
() => formatDescriptionForPreview(practiceDescription),
[practiceDescription],
);
const addQuestion = () => { const addQuestion = () => {
setQuestions([...questions, createEmptyQuestion(String(Date.now()))]); setQuestions([...questions, createEmptyQuestion(String(Date.now()))]);
}; };
@ -389,7 +293,12 @@ export function AddNewPracticePage() {
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
try { try {
const persona = PERSONAS.find((p) => p.id === selectedPersona); if (!selectedPersona) {
toast.error("Select a persona before saving.");
setSaving(false);
return;
}
const persona = personas.find((p) => p.id === selectedPersona);
const setRes = await createQuestionSet({ const setRes = await createQuestionSet({
title: practiceTitle || "Untitled Practice", title: practiceTitle || "Untitled Practice",
set_type: "PRACTICE", set_type: "PRACTICE",
@ -414,6 +323,38 @@ export function AddNewPracticePage() {
const q = questions[i]; const q = questions[i];
if (!q.questionText.trim()) continue; if (!q.questionText.trim()) continue;
if (q.questionType === "DYNAMIC") {
if (q.questionTypeDefinitionId == null || q.questionTypeDefinitionId <= 0) {
toast.error(`Question ${i + 1}: select a question type definition for dynamic questions.`);
setSaving(false);
return;
}
const missingStimulus = q.dynamicStimulusRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`stimulus:${row.id}`]?.trim()),
);
if (missingStimulus) {
toast.error(
`Question ${i + 1}: fill required stimulus "${missingStimulus.label || missingStimulus.id}".`,
);
setSaving(false);
return;
}
const missingResponse = q.dynamicResponseRows.find(
(row) =>
row.required &&
!(q.dynamicFieldValues[`response:${row.id}`]?.trim()),
);
if (missingResponse) {
toast.error(
`Question ${i + 1}: fill required response "${missingResponse.label || missingResponse.id}".`,
);
setSaving(false);
return;
}
}
const options: QuestionOption[] = const options: QuestionOption[] =
q.questionType === "MCQ" q.questionType === "MCQ"
? q.options.map((opt, idx) => ({ ? q.options.map((opt, idx) => ({
@ -423,22 +364,43 @@ export function AddNewPracticePage() {
})) }))
: []; : [];
const qRes = await createQuestion({ const dynamicPayload =
question_text: q.questionText, q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null
question_type: q.questionType, ? buildDynamicQuestionPayload({
difficulty_level: q.difficultyLevel, stimulusRows: q.dynamicStimulusRows,
points: q.points, responseRows: q.dynamicResponseRows,
tips: q.tips || undefined, fieldValues: q.dynamicFieldValues,
explanation: q.explanation || undefined, })
status: "PUBLISHED", : undefined;
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined, const qRes = await createQuestion(
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined, q.questionType === "DYNAMIC" && q.questionTypeDefinitionId != null && dynamicPayload
audio_correct_answer_text: q.audioCorrectAnswerText || undefined, ? {
image_url: q.imageUrl.trim() || undefined, question_type: "DYNAMIC",
short_answers: question_type_definition_id: q.questionTypeDefinitionId,
q.shortAnswers.length > 0 ? q.shortAnswers : undefined, dynamic_payload: dynamicPayload,
}); difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
}
: {
question_text: q.questionText,
question_type: q.questionType,
difficulty_level: q.difficultyLevel,
points: q.points,
tips: q.tips || undefined,
explanation: q.explanation || undefined,
status,
options: options.length > 0 ? options : undefined,
voice_prompt: q.voicePrompt || undefined,
sample_answer_voice_prompt: q.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: q.audioCorrectAnswerText || undefined,
image_url: q.imageUrl.trim() || undefined,
short_answers: q.shortAnswers.length > 0 ? q.shortAnswers : undefined,
},
);
const questionId = qRes.data?.data?.id; const questionId = qRes.data?.data?.id;
if (questionId) { if (questionId) {
@ -841,66 +803,17 @@ export function AddNewPracticePage() {
practice. practice.
</p> </p>
</div> </div>
<div className="p-5 sm:p-8 lg:p-10"> <div className="p-5 sm:p-8 lg:p-10">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-4 lg:gap-5"> <PersonaStep
{PERSONAS.map((persona) => ( personas={personas}
<button loading={personasLoading}
key={persona.id} error={personasError}
onClick={() => setSelectedPersona(persona.id)} onRetry={() => void reloadPersonas()}
className={`group relative flex flex-col items-center rounded-xl border-2 p-6 transition-all duration-200 ${ selectedPersona={selectedPersona}
selectedPersona === persona.id setSelectedPersona={setSelectedPersona}
? "border-brand-500 bg-brand-50 shadow-md shadow-brand-100" nextStep={handleNext}
: "border-grayScale-200 bg-white hover:border-brand-300 hover:shadow-sm" prevStep={handleBack}
}`} />
>
{selectedPersona === persona.id && (
<div className="absolute right-2.5 top-2.5 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white shadow-sm">
<Check className="h-3.5 w-3.5" />
</div>
)}
<div
className={`mb-3 h-20 w-20 overflow-hidden rounded-full bg-grayScale-100 ring-2 transition-all duration-200 ${
selectedPersona === persona.id
? "ring-brand-300 ring-offset-2"
: "ring-transparent group-hover:ring-grayScale-200"
}`}
>
<img
src={persona.avatar}
alt={persona.name}
className="h-full w-full object-cover"
/>
</div>
<span
className={`text-sm font-semibold transition-colors ${
selectedPersona === persona.id
? "text-brand-600"
: "text-grayScale-900"
}`}
>
{persona.name}
</span>
</button>
))}
</div>
</div>
<div className="flex flex-col-reverse items-stretch justify-between gap-3 border-t border-grayScale-100 bg-grayScale-50/30 px-5 py-4 sm:flex-row sm:items-center sm:px-8 sm:py-5">
<Button
variant="outline"
onClick={handleBack}
className="sm:w-auto"
>
Back
</Button>
<Button
className="w-full bg-brand-500 hover:bg-brand-600 sm:w-auto sm:min-w-[180px]"
onClick={handleNext}
>
{getNextButtonLabel()}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div> </div>
</Card> </Card>
)} )}
@ -912,7 +825,7 @@ export function AddNewPracticePage() {
Step 3: Questions Step 3: Questions
</h2> </h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500"> <p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500">
Add MCQ, True/False, Short Answer, or Audio items. Use the full Add MCQ, True/False, Short Answer, Audio, or Dynamic (schema-driven) items. Use the full
width for stems and options. width for stems and options.
</p> </p>
</div> </div>
@ -952,6 +865,10 @@ export function AddNewPracticePage() {
audioCorrectAnswerText: question.audioCorrectAnswerText, audioCorrectAnswerText: question.audioCorrectAnswerText,
shortAnswer: question.shortAnswers[0] ?? "", shortAnswer: question.shortAnswers[0] ?? "",
imageUrl: question.imageUrl, imageUrl: question.imageUrl,
questionTypeDefinitionId: question.questionTypeDefinitionId,
dynamicStimulusRows: question.dynamicStimulusRows,
dynamicResponseRows: question.dynamicResponseRows,
dynamicFieldValues: question.dynamicFieldValues,
}} }}
onChange={(next) => { onChange={(next) => {
updateQuestion(question.id, { updateQuestion(question.id, {
@ -970,6 +887,10 @@ export function AddNewPracticePage() {
? [next.shortAnswer.trim()] ? [next.shortAnswer.trim()]
: [], : [],
imageUrl: next.imageUrl, imageUrl: next.imageUrl,
questionTypeDefinitionId: next.questionTypeDefinitionId,
dynamicStimulusRows: next.dynamicStimulusRows,
dynamicResponseRows: next.dynamicResponseRows,
dynamicFieldValues: next.dynamicFieldValues,
}); });
}} }}
mediaBusy={saving} mediaBusy={saving}
@ -1009,259 +930,26 @@ export function AddNewPracticePage() {
)} )}
{currentStep === 4 && ( {currentStep === 4 && (
<div className="w-full space-y-6"> <AddNewPracticeReviewStep
<div className="rounded-2xl border border-grayScale-200/80 bg-gradient-to-r from-grayScale-50/80 to-white px-5 py-5 shadow-sm sm:px-8 sm:py-6"> practiceTitle={practiceTitle}
<h2 className="text-lg font-semibold tracking-tight text-grayScale-900 sm:text-xl"> practiceDescription={practiceDescription}
Step 4: Review & publish selectedProgram={selectedProgram}
</h2> selectedCourse={selectedCourse}
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-grayScale-500"> moduleLabel={
Confirm context, persona, and questions before saving or subModuleId ? `Module ${subModuleId}` : "Current module"
publishing. }
</p> selectedPersona={selectedPersona}
</div> personas={personas}
introVideoPreview={introVideoPreview}
<div className="grid gap-6 lg:grid-cols-2 lg:items-start lg:gap-8"> questions={questions}
{/* Basic Information Card */} saving={saving}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm"> saveError={saveError}
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4"> onEditContext={() => setCurrentStep(1)}
<h3 className="font-semibold text-grayScale-900"> onEditQuestions={() => setCurrentStep(3)}
Basic Information onBack={handleBack}
</h3> onSaveDraft={handleSaveAsDraft}
<button onPublish={handlePublish}
onClick={() => setCurrentStep(1)} />
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
</div>
<div className="divide-y divide-grayScale-100">
<div className="flex justify-between px-6 py-3.5 odd:bg-grayScale-50/50">
<span className="text-sm text-grayScale-500">Title</span>
<span className="text-sm font-medium text-grayScale-900">
{practiceTitle || "Untitled Practice"}
</span>
</div>
<div className="bg-grayScale-50/50 px-6 py-4">
<span className="text-sm text-grayScale-500">
Description
</span>
{descriptionPreviewHtml ? (
<div
className="mt-2 rounded-lg border border-grayScale-200 bg-white px-4 py-3 text-sm leading-relaxed text-grayScale-800 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-2 [&_strong]:font-semibold [&_ul]:list-disc [&_ul]:pl-6"
dangerouslySetInnerHTML={{
__html: descriptionPreviewHtml,
}}
/>
) : (
<p className="mt-2 text-sm text-grayScale-400"></p>
)}
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Intro video URL
</span>
<span className="max-w-[min(28rem,55%)] break-all text-right text-sm text-grayScale-700">
{introVideoUrl.trim() || "—"}
</span>
</div>
{introVideoPreview ? (
<div className="bg-grayScale-50/50 px-6 py-4">
<span className="text-sm text-grayScale-500">
Intro video preview
</span>
<div className="mt-2 rounded-lg border border-grayScale-200 bg-white p-3">
{introVideoPreview.kind === "vimeo" ? (
<div className="overflow-hidden rounded-lg border border-grayScale-200 bg-black">
<iframe
src={introVideoPreview.url}
title="Intro video preview"
className="aspect-video w-full"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
/>
</div>
) : (
<video
controls
src={introVideoPreview.url}
className="aspect-video w-full rounded-lg border border-grayScale-200 bg-black"
/>
)}
</div>
</div>
) : null}
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Passing Score
</span>
<span className="text-sm font-medium text-grayScale-900">
{passingScore}%
</span>
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Time Limit
</span>
<span className="text-sm font-medium text-grayScale-900">
{timeLimitMinutes} minutes
</span>
</div>
<div className="flex justify-between bg-grayScale-50/50 px-6 py-3.5">
<span className="text-sm text-grayScale-500">
Shuffle Questions
</span>
<span className="text-sm font-medium text-grayScale-900">
{shuffleQuestions ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between px-6 py-3.5">
<span className="text-sm text-grayScale-500">Persona</span>
<div className="flex items-center gap-2">
{selectedPersona && (
<div className="h-6 w-6 overflow-hidden rounded-full bg-grayScale-100 ring-2 ring-brand-100">
<img
src={
PERSONAS.find((p) => p.id === selectedPersona)
?.avatar
}
alt="Persona"
className="h-full w-full object-cover"
/>
</div>
)}
<span className="text-sm font-medium text-brand-600">
{PERSONAS.find((p) => p.id === selectedPersona)?.name ||
"None selected"}
</span>
</div>
</div>
</div>
</Card>
{/* Questions Review */}
<Card className="overflow-hidden border-grayScale-200/80 p-0 shadow-sm lg:min-h-0">
<div className="flex items-center justify-between border-b border-grayScale-100 px-6 py-4">
<div className="flex items-center gap-2.5">
<h3 className="font-semibold text-grayScale-900">
Questions
</h3>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-600">
{questions.length}
</span>
</div>
<button
onClick={() => setCurrentStep(3)}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium text-brand-500 transition-colors hover:bg-brand-50 hover:text-brand-600"
>
<Edit className="h-3.5 w-3.5" />
Edit
</button>
</div>
<div className="max-h-[min(70vh,52rem)] space-y-3 overflow-y-auto px-4 py-4 sm:px-6">
{questions.map((question, index) => (
<div
key={question.id}
className="rounded-xl border border-grayScale-200 bg-grayScale-50/20 p-4 transition-colors hover:border-grayScale-300 sm:p-4"
>
<div className="flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-brand-100 text-xs font-bold text-brand-600">
{index + 1}
</span>
<div className="flex-1 space-y-2.5">
<p className="text-sm font-medium leading-relaxed text-grayScale-900">
{question.questionText}
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-md bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600">
{question.questionType === "MCQ"
? "Multiple Choice"
: question.questionType === "TRUE_FALSE"
? "True/False"
: question.questionType === "AUDIO"
? "Audio"
: "Short Answer"}
</span>
<span className="rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600">
{question.difficultyLevel}
</span>
<span className="rounded-md bg-grayScale-100 px-2 py-0.5 text-xs font-medium text-grayScale-600">
{question.points} pt
{question.points !== 1 ? "s" : ""}
</span>
</div>
{question.questionType === "MCQ" &&
question.options.length > 0 && (
<div className="mt-2 space-y-1">
{question.options.map((opt, i) => (
<div
key={i}
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm ${
opt.isCorrect
? "bg-green-50 font-medium text-green-700"
: "text-grayScale-600"
}`}
>
{opt.isCorrect && (
<Check className="h-3.5 w-3.5" />
)}
{opt.text || `Option ${i + 1}`}
</div>
))}
</div>
)}
{question.tips && (
<p className="rounded-md bg-amber-50 px-2.5 py-1.5 text-xs text-amber-600">
💡 Tip: {question.tips}
</p>
)}
{question.explanation && (
<p className="rounded-md bg-grayScale-50 px-2.5 py-1.5 text-xs text-grayScale-500">
Explanation: {question.explanation}
</p>
)}
</div>
</div>
</div>
))}
</div>
</Card>
</div>
{saveError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p className="text-sm font-medium text-red-600">{saveError}</p>
</div>
)}
<div className="flex flex-col-reverse items-stretch justify-between gap-3 rounded-2xl border border-grayScale-200/80 bg-grayScale-50/30 px-4 py-4 sm:flex-row sm:items-center sm:px-6 sm:py-5">
<Button
variant="outline"
onClick={handleBack}
className="sm:w-auto"
>
Back
</Button>
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={handleSaveAsDraft}
disabled={saving}
className="sm:min-w-[140px]"
>
{saving ? "Saving..." : "Save as Draft"}
</Button>
<Button
className="bg-brand-500 hover:bg-brand-600 sm:min-w-[160px]"
onClick={handlePublish}
disabled={saving}
>
<Rocket className="mr-2 h-4 w-4" />
{saving ? "Publishing..." : "Publish Now"}
</Button>
</div>
</div>
</div>
)} )}
{/* Step 5: Result */} {/* Step 5: Result */}

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Link, Link,
useNavigate, useNavigate,
@ -6,70 +6,370 @@ import {
useSearchParams, useSearchParams,
} from "react-router-dom"; } from "react-router-dom";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper"; import { Stepper } from "../../components/ui/stepper";
import successIcon from "../../assets/success.svg"; import successIcon from "../../assets/success.svg";
import type { PracticeParentKind } from "../../types/course.types";
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types";
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion";
import {
executeLearnEnglishPracticeCreation,
learnEnglishPracticeApiErrorMessage,
validateLearnEnglishQuestionsWithDefinitions,
} from "../../lib/learnEnglishPracticePublish";
import { ContextStep } from "./components/practice-steps/ContextStep"; import { ContextStep } from "./components/practice-steps/ContextStep";
import { ScenarioStep } from "./components/practice-steps/ScenarioStep"; import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
import { PersonaStep } from "./components/practice-steps/PersonaStep"; import { PersonaStep } from "./components/practice-steps/PersonaStep";
import { QuestionsStep } from "./components/practice-steps/QuestionsStep"; import { QuestionsStep } from "./components/practice-steps/QuestionsStep";
import { ReviewStep } from "./components/practice-steps/ReviewStep"; import { ReviewStep } from "./components/practice-steps/ReviewStep";
import {
personaFromId,
personaIdNumber,
} from "./components/practice-steps/constants";
import { useActivePersonas } from "../../hooks/useActivePersonas";
const STEP_LABELS = ["Practice", "Persona", "Questions", "Review"] as const;
export function AddPracticeFlow() { export function AddPracticeFlow() {
const navigate = useNavigate(); const navigate = useNavigate();
const { level } = useParams<{ level: string }>(); const {
level,
programType,
courseId: routeCourseId,
unitId: routeUnitId,
moduleId: routeModuleId,
} = useParams<{
level?: string;
programType?: string;
courseId?: string;
unitId?: string;
moduleId?: string;
}>();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const backTo = searchParams.get("backTo"); const backToParam = searchParams.get("backTo");
const courseId = searchParams.get("courseId"); const lessonId = searchParams.get("lessonId");
const moduleId = searchParams.get("moduleId"); const lessonTitleRaw = searchParams.get("lessonTitle");
const isModuleContext = backTo === "module"; const isExamPrep = Boolean(programType?.trim());
const isCourseContext = backTo === "modules";
const effectiveBackTo = useMemo(() => {
if (backToParam?.trim()) return backToParam.trim();
if (isExamPrep && routeModuleId) return "module";
if (isExamPrep && routeCourseId) return "courses";
return null;
}, [backToParam, isExamPrep, routeModuleId, routeCourseId]);
const courseId = isExamPrep
? routeCourseId ?? searchParams.get("courseId")
: searchParams.get("courseId");
const moduleId = isExamPrep
? routeModuleId ?? searchParams.get("moduleId")
: 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 === "modules" || effectiveBackTo === "courses";
const isLessonPractice = useMemo(() => {
const lid = lessonId ? Number(lessonId) : NaN;
return Number.isFinite(lid) && lid > 0;
}, [lessonId]);
/** Learn English lesson practices skip story fields; exam prep lessons use the full form. */
const isLearnEnglishLessonPractice = isLessonPractice && !isExamPrep;
const parentContext = useMemo((): {
kind: PracticeParentKind;
id: number;
} | null => {
const lid = lessonId ? Number(lessonId) : NaN;
if (Number.isFinite(lid) && lid > 0) return { kind: "LESSON", id: lid };
const mid = moduleId ? Number(moduleId) : NaN;
if (isModuleContext && Number.isFinite(mid) && mid > 0)
return { kind: "MODULE", id: mid };
const cid = courseId ? Number(courseId) : NaN;
if (isCourseContext && Number.isFinite(cid) && cid > 0)
return { kind: "COURSE", id: cid };
return null;
}, [lessonId, moduleId, courseId, isModuleContext, isCourseContext]);
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 = const backLabel =
backTo === "module" effectiveBackTo === "module"
? "Back to Module" ? "Back to Module"
: backTo === "modules" : effectiveBackTo === "modules"
? "Back to Modules" ? "Back to Modules"
: "Back to Courses"; : effectiveBackTo === "courses"
const backPath = ? "Back to Course"
backTo === "module" && courseId && moduleId : isExamPrep
? `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}` ? "Back to Program"
: backTo === "modules" && courseId : "Back to Courses";
? `/new-content/learn-english/${level}/courses/${courseId}`
: `/new-content/learn-english/${level}/courses`;
const flowSteps = isModuleContext const backPath = useMemo(() => {
? ["Context", "Persona", "Questions", "Review"] if (isExamPrep) {
: ["Context", "Scenario", "Persona", "Questions", "Review"]; 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 (effectiveBackTo === "module" && level && courseId && moduleId) {
return `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
}
if (effectiveBackTo === "modules" && level && courseId) {
return `/new-content/learn-english/${level}/courses/${courseId}`;
}
return `/new-content/learn-english/${level}/courses`;
}, [
isExamPrep,
effectiveBackTo,
programType,
courseId,
unitId,
moduleId,
level,
]);
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [selectedPersona, setSelectedPersona] = useState<string | null>(
"dawit",
);
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const {
personas,
loading: personasLoading,
error: personasError,
reload: reloadPersonas,
} = useActivePersonas();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
program: "Intermediate",
course: "A2",
title: "", title: "",
description: "", description: "",
selectedVideo: "", storyImageUrl: "",
tips: "Focus on using the present perfect continuous tense to describe an action that started in the past and continues now.", shuffleQuestions: false,
tips: "",
questions: [ questions: [
{ {
id: "q1", id: "q1",
text: "How long have you been studying English?", questionTypeDefinitionId: null as number | null,
type: "Speaking", text: "",
voicePrompt: "prompt_q1_en.mp3", dynamicFieldValues: {} as Record<string, string>,
sampleAnswer: "prompt_q1_en.mp3", 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 (typeDefinitions.length === 0) return;
setFormData((fd) => ({
...fd,
questions: fd.questions.map((q) => {
if (q.questionTypeDefinitionId != null) return q;
const def = typeDefinitions[0];
return {
...q,
questionTypeDefinitionId: def.id,
dynamicFieldValues: emptyDynamicFieldValuesForDefinition(def),
};
}),
}));
}, [typeDefinitions]);
const submitPractice = async (status: "DRAFT" | "PUBLISHED") => {
if (!parentContext) {
toast.error("Missing practice parent", {
description:
"Open this screen from a course, module, or lesson so the API receives parent_kind and parent_id.",
});
return;
}
if (
!isLearnEnglishLessonPractice &&
(!formData.title.trim() || !formData.description.trim())
) {
toast.error("Title and story description are required", {
description: "Complete the first step before publishing.",
});
return;
}
if (!selectedPersona) {
toast.error("Select a persona", {
description: "Choose a character on the Persona step before publishing.",
});
return;
}
const personaId = personaIdNumber(selectedPersona);
if (!personaId) {
toast.error("Invalid persona", {
description: "Re-select a persona from the list and try again.",
});
return;
}
const persona = personaFromId(selectedPersona, personas);
const mappedQuestions = formData.questions.map((q) => ({
questionText: String(q.text ?? "").trim(),
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
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");
const useExamPrepLessonApi =
isExamPrep &&
isLessonPractice &&
parentContext.kind === "LESSON" &&
Number.isFinite(parentContext.id);
setSubmitting(true);
try {
await executeLearnEnglishPracticeCreation({
parentKind: parentContext.kind,
parentId: parentContext.id,
examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined,
status,
questionSetTitle: isLearnEnglishLessonPractice
? lessonDefaultTitle
: formData.title.trim() || "Practice set",
questionSetDescription: isLearnEnglishLessonPractice
? null
: formData.description.trim() || null,
shuffleQuestions: formData.shuffleQuestions,
practiceTitle: isLearnEnglishLessonPractice
? lessonDefaultTitle
: formData.title.trim() || "Untitled practice",
storyDescription: isLearnEnglishLessonPractice
? ""
: formData.description.trim(),
storyImage: isLearnEnglishLessonPractice
? ""
: formData.storyImageUrl.trim(),
quickTips: formData.tips.trim(),
personaName: persona?.name ?? null,
personaId,
questions: mappedQuestions,
definitions: typeDefinitions,
});
toast.success(
status === "PUBLISHED" ? "Practice published" : "Draft saved",
{
description:
"Question set, questions, and parent-linked practice were created.",
},
);
setIsPublished(true);
} catch (e) {
toast.error("Could not save practice", {
description: learnEnglishPracticeApiErrorMessage(e),
});
} finally {
setSubmitting(false);
}
};
const nextStep = () => const nextStep = () =>
setCurrentStep((prev) => Math.min(prev + 1, flowSteps.length)); setCurrentStep((prev) => Math.min(prev + 1, STEP_LABELS.length));
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1)); const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 1));
if (isPublished) { if (isPublished) {
@ -87,23 +387,47 @@ export function AddPracticeFlow() {
Practice Published Successfully! Practice Published Successfully!
</h1> </h1>
<p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed"> <p className="text-grayScale-600 text-md mb-14 max-w-lg font-medium leading-relaxed">
Your speaking practice is now active and available inside the module. {lessonId
? "Your speaking practice is saved and linked to this lessons question set."
: "Your speaking practice is saved for the linked course or module."}
</p> </p>
<div className="flex flex-col gap-4 w-full max-w-[400px]"> <div className="flex flex-col gap-4 w-full max-w-[400px]">
<Button <Button
onClick={() => navigate(backPath)} onClick={() => navigate(backPath)}
className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white " className="h-14 rounded-[6px] bg-[#9E2891] font-bold shadow-xl shadow-brand-500/20 text-[16px] text-white "
> >
Go back to Module {backLabel}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setIsPublished(false); setIsPublished(false);
setCurrentStep(1); setCurrentStep(1);
setSelectedPersona(null);
setFormData({ setFormData({
...formData,
title: "", title: "",
description: "", description: "",
storyImageUrl: "",
shuffleQuestions: false,
tips: "",
questions: [
{
id: "q1",
questionTypeDefinitionId:
typeDefinitions[0]?.id ?? (null as number | null),
text: "",
dynamicFieldValues: typeDefinitions[0]
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
: {},
mcqOptions: [
{ text: "", isCorrect: true },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
{ text: "", isCorrect: false },
],
trueFalseCorrect: true,
shortAnswers: [""],
},
],
}); });
}} }}
variant="outline" variant="outline"
@ -116,9 +440,8 @@ export function AddPracticeFlow() {
); );
} }
// Helper to map currentStep to the actual component for the module flow
const renderStep = () => { const renderStep = () => {
if (!isModuleContext) { if (isModuleContext) {
switch (currentStep) { switch (currentStep) {
case 1: case 1:
return ( return (
@ -126,70 +449,19 @@ export function AddPracticeFlow() {
formData={formData} formData={formData}
setFormData={setFormData} setFormData={setFormData}
nextStep={nextStep} nextStep={nextStep}
navigate={navigate} onCancel={() => navigate(backPath)}
level={level!} isLessonPractice={isLearnEnglishLessonPractice}
isModuleContext={isModuleContext} lessonTitle={lessonTitleDisplay}
isCourseContext={isCourseContext} parentSummary={parentSummary}
/>
);
case 2:
return (
<ScenarioStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 3:
return (
<PersonaStep
selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 4:
return (
<QuestionsStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
prevStep={prevStep}
/>
);
case 5:
return (
<ReviewStep
formData={formData}
selectedPersona={selectedPersona}
prevStep={prevStep}
setIsPublished={setIsPublished}
isModuleContext={isModuleContext}
/>
);
default:
return null;
}
} else {
// Module Context Flow (Skips Scenario)
switch (currentStep) {
case 1:
return (
<ContextStep
formData={formData}
setFormData={setFormData}
nextStep={nextStep}
navigate={navigate}
level={level!}
isModuleContext={isModuleContext}
isCourseContext={isCourseContext}
/> />
); );
case 2: case 2:
return ( return (
<PersonaStep <PersonaStep
personas={personas}
loading={personasLoading}
error={personasError}
onRetry={() => void reloadPersonas()}
selectedPersona={selectedPersona} selectedPersona={selectedPersona}
setSelectedPersona={setSelectedPersona} setSelectedPersona={setSelectedPersona}
nextStep={nextStep} nextStep={nextStep}
@ -203,6 +475,9 @@ export function AddPracticeFlow() {
setFormData={setFormData} setFormData={setFormData}
nextStep={nextStep} nextStep={nextStep}
prevStep={prevStep} prevStep={prevStep}
typeDefinitions={typeDefinitions}
definitionsLoading={definitionsLoading}
definitionsError={definitionsError}
/> />
); );
case 4: case 4:
@ -210,20 +485,92 @@ export function AddPracticeFlow() {
<ReviewStep <ReviewStep
formData={formData} formData={formData}
selectedPersona={selectedPersona} selectedPersona={selectedPersona}
personas={personas}
isLessonPractice={isLearnEnglishLessonPractice}
lessonTitle={lessonTitleDisplay}
programLabel={programLabel}
courseLabel={courseId ? `Course ${courseId}` : null}
moduleLabel={moduleId ? `Module ${moduleId}` : null}
prevStep={prevStep} prevStep={prevStep}
setIsPublished={setIsPublished} onEditContext={() => setCurrentStep(1)}
isModuleContext={isModuleContext} onEditQuestions={() => setCurrentStep(3)}
parentSummary={parentSummary}
typeDefinitions={typeDefinitions}
canPublish={parentContext !== null}
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
/> />
); );
default: default:
return null; 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={parentContext !== null}
submitting={submitting}
onSaveDraft={() => void submitPractice("DRAFT")}
onPublish={() => void submitPractice("PUBLISHED")}
/>
);
default:
return null;
}
}; };
return ( return (
<div className="space-y-8 pb-32 px-6 pt-6 min-h-screen "> <div className="space-y-8 pb-32 px-6 pt-6 min-h-screen ">
{/* Header */}
<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
@ -249,16 +596,34 @@ export function AddPracticeFlow() {
</Button> </Button>
</div> </div>
<p className="text-grayScale-400 text-base"> <p className="text-grayScale-400 text-base">
Create a new immersive practice session for students. Create a practice with story details, a persona, and questions from your question type library.
</p> </p>
{lessonId ? (
<div className="mt-4 rounded-xl border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-950">
<p className="font-semibold text-violet-900">Lesson practice</p>
<p className="mt-1 text-violet-800/90">
Linked to lesson{" "}
<span className="font-mono font-bold text-violet-950">
#{lessonId}
</span>
{lessonTitleDisplay ? (
<>
{" "}
<span className="font-medium">{lessonTitleDisplay}</span>
</>
) : null}
.
</p>
</div>
) : null}
</div> </div>
<div className="mx-auto w-[70%] mb-12"> <div className="mx-auto w-[70%] mb-12">
<Stepper steps={flowSteps} currentStep={currentStep} /> <Stepper steps={[...STEP_LABELS]} currentStep={currentStep} />
</div> </div>
<div <div
className={`mx-auto ${(!isModuleContext && currentStep === 3) || (isModuleContext && currentStep === 2) || currentStep === 5 ? "max-w-6xl" : "max-w-4xl"}`} className={`mx-auto ${currentStep === 3 || currentStep === 4 ? "max-w-6xl" : "max-w-4xl"}`}
> >
{renderStep()} {renderStep()}
</div> </div>

View File

@ -8,11 +8,21 @@ import { Input } from "../../components/ui/input"
import { Textarea } from "../../components/ui/textarea" import { Textarea } from "../../components/ui/textarea"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api" import { createQuestion, getQuestionById, updateQuestion } from "../../api/courses.api"
import {
getQuestionTypeDefinitions,
questionTypeDefinitionListLabel,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" type QuestionType = "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | "AUDIO" | "DYNAMIC"
type Difficulty = "EASY" | "MEDIUM" | "HARD" type Difficulty = "EASY" | "MEDIUM" | "HARD"
type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE" type QuestionStatus = "DRAFT" | "PUBLISHED" | "INACTIVE"
const defaultDynamicPayloadJson = `{
"stimulus": [],
"response": []
}`
interface Question { interface Question {
id?: number id?: number
question: string question: string
@ -27,6 +37,9 @@ interface Question {
voicePrompt: string voicePrompt: string
sampleAnswerVoicePrompt: string sampleAnswerVoicePrompt: string
audioCorrectAnswerText: string audioCorrectAnswerText: string
/** Definition id as string for select value */
questionTypeDefinitionId: string
dynamicPayloadJson: string
} }
const initialForm: Question = { const initialForm: Question = {
@ -42,6 +55,8 @@ const initialForm: Question = {
voicePrompt: "", voicePrompt: "",
sampleAnswerVoicePrompt: "", sampleAnswerVoicePrompt: "",
audioCorrectAnswerText: "", audioCorrectAnswerText: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
} }
export function AddQuestionPage() { export function AddQuestionPage() {
@ -52,6 +67,7 @@ export function AddQuestionPage() {
const [formData, setFormData] = useState<Question>(initialForm) const [formData, setFormData] = useState<Question>(initialForm)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [typeDefinitions, setTypeDefinitions] = useState<QuestionTypeDefinition[]>([])
useEffect(() => { useEffect(() => {
const loadQuestion = async () => { const loadQuestion = async () => {
@ -64,7 +80,8 @@ export function AddQuestionPage() {
q.question_type === "MCQ" || q.question_type === "MCQ" ||
q.question_type === "TRUE_FALSE" || q.question_type === "TRUE_FALSE" ||
q.question_type === "SHORT_ANSWER" || q.question_type === "SHORT_ANSWER" ||
q.question_type === "AUDIO" q.question_type === "AUDIO" ||
q.question_type === "DYNAMIC"
? q.question_type ? q.question_type
: "MCQ" : "MCQ"
const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0 const shortAnswer = Array.isArray(q.short_answers) && q.short_answers.length > 0
@ -100,6 +117,14 @@ export function AddQuestionPage() {
voicePrompt: q.voice_prompt || "", voicePrompt: q.voice_prompt || "",
sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "", sampleAnswerVoicePrompt: q.sample_answer_voice_prompt || "",
audioCorrectAnswerText: q.audio_correct_answer_text || "", audioCorrectAnswerText: q.audio_correct_answer_text || "",
questionTypeDefinitionId:
mappedType === "DYNAMIC" && q.question_type_definition_id != null
? String(q.question_type_definition_id)
: "",
dynamicPayloadJson:
mappedType === "DYNAMIC" && q.dynamic_payload
? JSON.stringify(q.dynamic_payload, null, 2)
: defaultDynamicPayloadJson,
}) })
} catch (error) { } catch (error) {
console.error("Failed to load question:", error) console.error("Failed to load question:", error)
@ -111,6 +136,22 @@ export function AddQuestionPage() {
loadQuestion() loadQuestion()
}, [isEditing, id]) }, [isEditing, id])
useEffect(() => {
if (formData.type !== "DYNAMIC") return
let cancelled = false
;(async () => {
try {
const { definitions: rows } = await getQuestionTypeDefinitions({ include_system: true })
if (!cancelled) setTypeDefinitions(rows)
} catch {
if (!cancelled) setTypeDefinitions([])
}
})()
return () => {
cancelled = true
}
}, [formData.type])
const handleTypeChange = (type: QuestionType) => { const handleTypeChange = (type: QuestionType) => {
setFormData((prev) => { setFormData((prev) => {
if (type === "TRUE_FALSE") { if (type === "TRUE_FALSE") {
@ -120,6 +161,15 @@ export function AddQuestionPage() {
options: ["True", "False"], options: ["True", "False"],
correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "", correctAnswer: prev.correctAnswer === "True" || prev.correctAnswer === "False" ? prev.correctAnswer : "",
} }
} else if (type === "DYNAMIC") {
return {
...prev,
type,
options: [],
correctAnswer: "",
questionTypeDefinitionId: "",
dynamicPayloadJson: defaultDynamicPayloadJson,
}
} else if (type === "SHORT_ANSWER" || type === "AUDIO") { } else if (type === "SHORT_ANSWER" || type === "AUDIO") {
return { return {
...prev, ...prev,
@ -200,6 +250,27 @@ export function AddQuestionPage() {
}) })
return return
} }
} else if (formData.type === "DYNAMIC") {
const defId = Number(formData.questionTypeDefinitionId)
if (!Number.isFinite(defId) || defId < 1) {
toast.error("Definition required", { description: "Select a question type definition." })
return
}
try {
const parsed = JSON.parse(formData.dynamicPayloadJson || "{}") as {
stimulus?: unknown
response?: unknown
}
if (!Array.isArray(parsed.stimulus) || !Array.isArray(parsed.response)) {
toast.error("Invalid dynamic payload", {
description: 'JSON must include "stimulus" and "response" arrays.',
})
return
}
} catch {
toast.error("Invalid JSON", { description: "Fix the dynamic content JSON before saving." })
return
}
} }
setSubmitting(true) setSubmitting(true)
@ -221,6 +292,18 @@ export function AddQuestionPage() {
{ acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const }, { acceptable_answer: formData.correctAnswer.trim(), match_type: "CASE_INSENSITIVE" as const },
] ]
: undefined : undefined
let dynamicPayload: { stimulus: unknown[]; response: unknown[] } | undefined
if (formData.type === "DYNAMIC") {
try {
dynamicPayload = JSON.parse(formData.dynamicPayloadJson) as {
stimulus: unknown[]
response: unknown[]
}
} catch {
dynamicPayload = { stimulus: [], response: [] }
}
}
const payload = { const payload = {
question_text: formData.question, question_text: formData.question,
question_type: formData.type, question_type: formData.type,
@ -236,6 +319,12 @@ export function AddQuestionPage() {
formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined, formData.type === "AUDIO" ? formData.sampleAnswerVoicePrompt : formData.sampleAnswerVoicePrompt || undefined,
audio_correct_answer_text: audio_correct_answer_text:
formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined, formData.type === "AUDIO" ? formData.audioCorrectAnswerText : undefined,
...(formData.type === "DYNAMIC" && dynamicPayload
? {
question_type_definition_id: Number(formData.questionTypeDefinitionId),
dynamic_payload: dynamicPayload,
}
: {}),
} }
if (isEditing && id) { if (isEditing && id) {
await updateQuestion(Number(id), payload) await updateQuestion(Number(id), payload)
@ -257,68 +346,105 @@ export function AddQuestionPage() {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-4 pb-6">
{/* Page Header */} <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => navigate("/content/questions")} onClick={() => navigate("/content/questions")}
className="rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500 transition-colors" className="h-9 w-9 shrink-0 rounded-lg bg-grayScale-50 hover:bg-brand-500/10 hover:text-brand-500"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-600"> <h1 className="text-lg font-bold tracking-tight text-grayScale-800 sm:text-xl">
{isEditing ? "Edit Question" : "Add New Question"} {isEditing ? "Edit Question" : "Add New Question"}
</h1> </h1>
<p className="mt-1 text-sm text-grayScale-400"> <p className="mt-0.5 text-xs text-grayScale-500 sm:text-sm">
{isEditing ? "Update the question details below" : "Fill in the details to create a new question"} {isEditing ? "Update fields below" : "Create a bank question"}
</p> </p>
</div> </div>
</div> </div>
<div className="max-w-3xl mx-auto"> <div className="mx-auto max-w-2xl">
{loading && ( {loading && (
<Card className="mb-4 border border-grayScale-200"> <Card className="mb-2 border border-grayScale-200">
<CardContent className="py-4 text-sm text-grayScale-500">Loading question details...</CardContent> <CardContent className="py-2.5 text-xs text-grayScale-500">Loading</CardContent>
</Card> </Card>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Card className="shadow-sm border border-grayScale-100 rounded-xl"> <Card className="rounded-lg border border-grayScale-100 shadow-sm">
<CardHeader className="pb-2"> <CardHeader className="space-y-0 px-4 py-3 sm:px-5">
<CardTitle className="text-lg font-semibold text-grayScale-600">Question Details</CardTitle> <CardTitle className="text-base font-semibold text-grayScale-700">Question details</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-7"> <CardContent className="space-y-3 px-4 pb-4 pt-0 sm:px-5 sm:pb-5">
{/* Question Type */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
Question Type Question Type
</label> </label>
<Select <Select
value={formData.type} value={formData.type}
onChange={(e) => handleTypeChange(e.target.value as QuestionType)} onChange={(e) => handleTypeChange(e.target.value as QuestionType)}
className="h-9 text-sm"
> >
<option value="MCQ">Multiple Choice</option> <option value="MCQ">Multiple Choice</option>
<option value="TRUE_FALSE">True/False</option> <option value="TRUE_FALSE">True/False</option>
<option value="SHORT_ANSWER">Short Answer</option> <option value="SHORT_ANSWER">Short Answer</option>
<option value="AUDIO">Audio</option> <option value="AUDIO">Audio</option>
<option value="DYNAMIC">Dynamic (schema-driven)</option>
</Select> </Select>
</div> </div>
{formData.type === "DYNAMIC" && (
<>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Question type definition <span className="text-red-500">*</span>
</label>
<Select
value={formData.questionTypeDefinitionId}
onChange={(e) =>
setFormData((prev) => ({ ...prev, questionTypeDefinitionId: e.target.value }))
}
required
className="h-9 text-sm"
>
<option value="">Select definition</option>
{typeDefinitions.map((d) => (
<option key={d.id} value={String(d.id)}>
{questionTypeDefinitionListLabel(d)}
</option>
))}
</Select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">
Dynamic content (JSON) <span className="text-red-500">*</span>
</label>
<Textarea
value={formData.dynamicPayloadJson}
onChange={(e) => setFormData((prev) => ({ ...prev, dynamicPayloadJson: e.target.value }))}
rows={7}
className="min-h-0 font-mono text-[11px] leading-snug"
spellCheck={false}
/>
</div>
</>
)}
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* Question Text */}
<div> <div>
<label htmlFor="question" className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label htmlFor="question" className="mb-1 block text-xs font-medium text-grayScale-600">
Question {formData.type === "DYNAMIC" ? "Title / stem" : "Question"}
</label> </label>
<Textarea <Textarea
id="question" id="question"
placeholder="Enter your question here..." placeholder="Enter your question here..."
value={formData.question} value={formData.question}
onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, question: e.target.value }))}
rows={3} rows={2}
className="min-h-[72px] text-sm"
required required
/> />
</div> </div>
@ -326,13 +452,11 @@ export function AddQuestionPage() {
{/* Options for Multiple Choice */} {/* Options for Multiple Choice */}
{(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && ( {(formData.type === "MCQ" || formData.type === "TRUE_FALSE") && (
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">Options</label>
Options <div className="space-y-1.5">
</label>
<div className="space-y-3">
{formData.options.map((option, index) => ( {formData.options.map((option, index) => (
<div key={index} className="flex items-center gap-2 group"> <div key={index} className="group flex items-center gap-1.5">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-grayScale-50 text-grayScale-400 text-xs font-medium flex items-center justify-center"> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-grayScale-100 text-[10px] font-medium text-grayScale-500">
{index + 1} {index + 1}
</span> </span>
<Input <Input
@ -340,6 +464,7 @@ export function AddQuestionPage() {
onChange={(e) => handleOptionChange(index, e.target.value)} onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={`Option ${index + 1}`} placeholder={`Option ${index + 1}`}
disabled={formData.type === "TRUE_FALSE"} disabled={formData.type === "TRUE_FALSE"}
className="h-9 text-sm"
required required
/> />
{formData.type === "MCQ" && formData.options.length > 2 && ( {formData.type === "MCQ" && formData.options.length > 2 && (
@ -348,17 +473,22 @@ export function AddQuestionPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => removeOption(index)} onClick={() => removeOption(index)}
className="opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-500 transition-all" className="h-8 w-8 shrink-0 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-50 hover:text-red-500"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
</div> </div>
))} ))}
{formData.type === "MCQ" && ( {formData.type === "MCQ" && (
<Button type="button" variant="outline" onClick={addOption} className="w-full mt-1 border-dashed border-grayScale-200 text-grayScale-400 hover:text-brand-500 hover:border-brand-500/30"> <Button
<Plus className="h-4 w-4" /> type="button"
Add Option variant="outline"
onClick={addOption}
className="mt-0.5 h-9 w-full border-dashed border-grayScale-200 text-xs text-grayScale-500 hover:border-brand-500/30 hover:text-brand-500"
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add option
</Button> </Button>
)} )}
</div> </div>
@ -368,9 +498,10 @@ export function AddQuestionPage() {
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* Correct Answer */} {/* Correct Answer */}
{formData.type !== "DYNAMIC" && (
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
{formData.type === "AUDIO" ? "Audio Correct Answer Text" : "Correct Answer"} {formData.type === "AUDIO" ? "Audio correct answer" : "Correct answer"}
</label> </label>
{formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? ( {formData.type === "MCQ" || formData.type === "TRUE_FALSE" ? (
<Select <Select
@ -378,6 +509,7 @@ export function AddQuestionPage() {
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ ...prev, correctAnswer: e.target.value })) setFormData((prev) => ({ ...prev, correctAnswer: e.target.value }))
} }
className="h-9 text-sm"
required required
> >
<option value="">Select correct answer</option> <option value="">Select correct answer</option>
@ -389,7 +521,7 @@ export function AddQuestionPage() {
</Select> </Select>
) : ( ) : (
<Textarea <Textarea
placeholder={formData.type === "AUDIO" ? "Enter audio correct answer text..." : "Enter the correct answer..."} placeholder={formData.type === "AUDIO" ? "Expected spoken answer…" : "Correct answer…"}
value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer} value={formData.type === "AUDIO" ? formData.audioCorrectAnswerText : formData.correctAnswer}
onChange={(e) => onChange={(e) =>
setFormData((prev) => setFormData((prev) =>
@ -399,24 +531,26 @@ export function AddQuestionPage() {
) )
} }
rows={2} rows={2}
className="min-h-[60px] text-sm"
required required
/> />
)} )}
</div> </div>
)}
<hr className="border-grayScale-100" /> <hr className="border-grayScale-100" />
{/* Points and Difficulty side by side */} {/* Points and Difficulty side by side */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
{/* Points */}
<div> <div>
<label htmlFor="points" className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label htmlFor="points" className="mb-1 block text-xs font-medium text-grayScale-600">
Points Points
</label> </label>
<Input <Input
id="points" id="points"
type="number" type="number"
min="1" min="1"
className="h-9 text-sm"
value={formData.points} value={formData.points}
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 })) setFormData((prev) => ({ ...prev, points: parseInt(e.target.value) || 1 }))
@ -425,14 +559,12 @@ export function AddQuestionPage() {
/> />
</div> </div>
{/* Difficulty */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">Difficulty</label>
Difficulty (Optional)
</label>
<Select <Select
value={formData.difficulty} value={formData.difficulty}
onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))} onChange={(e) => setFormData((prev) => ({ ...prev, difficulty: e.target.value as Difficulty }))}
className="h-9 text-sm"
> >
<option value="EASY">Easy</option> <option value="EASY">Easy</option>
<option value="MEDIUM">Medium</option> <option value="MEDIUM">Medium</option>
@ -443,12 +575,11 @@ export function AddQuestionPage() {
{/* Status */} {/* Status */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">Status</label>
Status
</label>
<Select <Select
value={formData.status} value={formData.status}
onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))} onChange={(e) => setFormData((prev) => ({ ...prev, status: e.target.value as QuestionStatus }))}
className="h-9 text-sm"
> >
<option value="DRAFT">Draft</option> <option value="DRAFT">Draft</option>
<option value="PUBLISHED">Published</option> <option value="PUBLISHED">Published</option>
@ -457,58 +588,71 @@ export function AddQuestionPage() {
</div> </div>
{(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && ( {(formData.type === "AUDIO" || formData.type === "SHORT_ANSWER") && (
<> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"} Voice prompt{formData.type === "AUDIO" ? "" : " (opt.)"}
</label> </label>
<Textarea <Textarea
value={formData.voicePrompt} value={formData.voicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, voicePrompt: e.target.value }))}
rows={2} rows={2}
placeholder="Please say your answer..." placeholder="URL or key…"
className="min-h-[60px] text-sm"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-500"> <label className="mb-1 block text-xs font-medium text-grayScale-600">
Sample Answer Voice Prompt{formData.type === "AUDIO" ? "" : " (Optional)"} Sample answer (voice){formData.type === "AUDIO" ? "" : " (opt.)"}
</label> </label>
<Textarea <Textarea
value={formData.sampleAnswerVoicePrompt} value={formData.sampleAnswerVoicePrompt}
onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, sampleAnswerVoicePrompt: e.target.value }))}
rows={2} rows={2}
placeholder="Sample spoken answer..." placeholder="URL or key…"
className="min-h-[60px] text-sm"
/> />
</div> </div>
</> </div>
)} )}
<div> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Tips (Optional)</label> <div>
<Input <label className="mb-1 block text-xs font-medium text-grayScale-600">Tips (opt.)</label>
value={formData.tips} <Input
onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))} value={formData.tips}
placeholder="Helpful tip for learners" onChange={(e) => setFormData((prev) => ({ ...prev, tips: e.target.value }))}
/> placeholder="Short tip"
className="h-9 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-grayScale-600">Explanation (opt.)</label>
<Textarea
value={formData.explanation}
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))}
rows={2}
placeholder="Why this answer"
className="min-h-[60px] text-sm"
/>
</div>
</div> </div>
<div> <div className="flex flex-col-reverse gap-2 border-t border-grayScale-100 pt-3 sm:flex-row sm:justify-end">
<label className="mb-1.5 block text-sm font-medium text-grayScale-500">Explanation (Optional)</label> <Button
<Textarea type="button"
value={formData.explanation} variant="outline"
onChange={(e) => setFormData((prev) => ({ ...prev, explanation: e.target.value }))} onClick={() => navigate("/content/questions")}
rows={2} className="h-9 w-full text-sm sm:w-auto"
placeholder="Explain why the answer is correct" >
/>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-6 border-t border-grayScale-100">
<Button type="button" variant="outline" onClick={() => navigate("/content/questions")} className="w-full sm:w-auto hover:bg-grayScale-50">
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={submitting || loading} className="bg-brand-500 hover:bg-brand-600 text-white w-full sm:w-auto shadow-sm hover:shadow-md transition-all"> <Button
{isEditing ? "Update Question" : "Create Question"} type="submit"
disabled={submitting || loading}
className="h-9 w-full bg-brand-500 text-sm text-white hover:bg-brand-600 sm:w-auto"
>
{isEditing ? "Update" : "Create"}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@ -5,6 +5,7 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Stepper } from "../../components/ui/stepper"; import { Stepper } from "../../components/ui/stepper";
import { createModuleLesson } from "../../api/courses.api"; import { createModuleLesson } from "../../api/courses.api";
import type { PracticePublishStatus } from "../../types/course.types";
import { VideoDetailStep } from "./components/video-steps/VideoDetailStep"; import { VideoDetailStep } from "./components/video-steps/VideoDetailStep";
import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep"; import { ReviewPublishStep } from "./components/video-steps/ReviewPublishStep";
@ -17,7 +18,7 @@ const STEPS = [
export type AddLessonFormData = { export type AddLessonFormData = {
title: string; title: string;
order: string; sortOrder: string;
description: string; description: string;
videoUrl: string; videoUrl: string;
thumbnailUrl: string; thumbnailUrl: string;
@ -25,7 +26,7 @@ export type AddLessonFormData = {
const emptyForm = (): AddLessonFormData => ({ const emptyForm = (): AddLessonFormData => ({
title: "", title: "",
order: "1", sortOrder: "0",
description: "", description: "",
videoUrl: "", videoUrl: "",
thumbnailUrl: "", thumbnailUrl: "",
@ -51,6 +52,8 @@ export function AddVideoFlow() {
}>(); }>();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [lastCreatedPublishStatus, setLastCreatedPublishStatus] =
useState<PracticePublishStatus>("PUBLISHED");
const [formData, setFormData] = useState<AddLessonFormData>(emptyForm); const [formData, setFormData] = useState<AddLessonFormData>(emptyForm);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [formResetKey, setFormResetKey] = useState(0); const [formResetKey, setFormResetKey] = useState(0);
@ -60,7 +63,7 @@ export function AddVideoFlow() {
const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`; const backPath = `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const handlePublish = async () => { const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
const mid = Number(moduleId); const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) { if (!Number.isFinite(mid) || mid < 1) {
toast.error("Invalid module"); toast.error("Invalid module");
@ -86,6 +89,16 @@ export function AddVideoFlow() {
toast.error("Description is required"); toast.error("Description is required");
return; return;
} }
const sortOrderRaw = formData.sortOrder.trim();
if (sortOrderRaw === "") {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setPublishing(true); setPublishing(true);
try { try {
await createModuleLesson(mid, { await createModuleLesson(mid, {
@ -93,8 +106,15 @@ export function AddVideoFlow() {
video_url: videoUrl, video_url: videoUrl,
thumbnail, thumbnail,
description, description,
sort_order,
publish_status: publishStatus,
}); });
toast.success("Lesson created"); setLastCreatedPublishStatus(publishStatus);
toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published",
);
setIsPublished(true); setIsPublished(true);
} catch (e: unknown) { } catch (e: unknown) {
console.error(e); console.error(e);
@ -123,10 +143,14 @@ export function AddVideoFlow() {
</div> </div>
<h1 className="text-[26px] font-bold text-grayScale-900 mb-4"> <h1 className="text-[26px] font-bold text-grayScale-900 mb-4">
Lesson created successfully {lastCreatedPublishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson published successfully"}
</h1> </h1>
<p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed"> <p className="text-grayScale-600 text-base mb-14 max-w-lg font-medium leading-relaxed">
Your lesson is now available in this module. {lastCreatedPublishStatus === "DRAFT"
? "You can finish editing and publish it later from the module."
: "Your lesson is now available in this module."}
</p> </p>
<div className="flex flex-col gap-4 w-full max-w-[400px]"> <div className="flex flex-col gap-4 w-full max-w-[400px]">
@ -140,6 +164,7 @@ export function AddVideoFlow() {
onClick={() => { onClick={() => {
setFormData(emptyForm()); setFormData(emptyForm());
setFormResetKey((k) => k + 1); setFormResetKey((k) => k + 1);
setLastCreatedPublishStatus("PUBLISHED");
setIsPublished(false); setIsPublished(false);
setCurrentStep(1); setCurrentStep(1);
}} }}
@ -205,7 +230,7 @@ export function AddVideoFlow() {
<ReviewPublishStep <ReviewPublishStep
formData={formData} formData={formData}
prevStep={prevStep} prevStep={prevStep}
onPublish={() => void handlePublish()} onCreateLesson={(status) => void handleCreateLesson(status)}
publishing={publishing} publishing={publishing}
/> />
)} )}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
ArrowLeft, ArrowLeft,
Plus, Plus,
@ -21,23 +21,33 @@ import {
DialogTitle, DialogTitle,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"; import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg"; import alertSrc from "../../assets/Alert.svg";
import { import {
deleteTopLevelCourseModule, deleteTopLevelCourseModule,
getPracticesByParentCourse,
getProgramCourses, getProgramCourses,
getTopLevelCourseModules, getTopLevelCourseModules,
publishParentLinkedPractice,
updateParentLinkedPractice,
updateTopLevelCourseModule, updateTopLevelCourseModule,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { refreshFileUrl, resolveFileUrl } from "../../api/files.api"; import { refreshFileUrl, resolveFileUrl } from "../../api/files.api";
import type { import type {
ParentContextPractice,
ProgramCourseListItem, ProgramCourseListItem,
TopLevelCourseModuleItem, TopLevelCourseModuleItem,
} from "../../types/course.types"; } from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { AddModuleModal } from "./components/AddModuleModal"; import { AddModuleModal } from "./components/AddModuleModal";
import { ModuleIconUploadField } from "./components/ModuleIconUploadField"; import { ModuleIconUploadField } from "./components/ModuleIconUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { PublishPracticeButton } from "./components/PublishPracticeButton";
const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const; const MODULE_CARD_GRADIENT = "from-[#8E44AD] to-[#C39BD3]" as const;
@ -145,7 +155,7 @@ export function CourseDetailPage() {
const [editingModule, setEditingModule] = const [editingModule, setEditingModule] =
useState<TopLevelCourseModuleItem | null>(null); useState<TopLevelCourseModuleItem | null>(null);
const [editModuleName, setEditModuleName] = useState(""); const [editModuleName, setEditModuleName] = useState("");
const [editModuleDescription, setEditModuleDescription] = useState(""); const [editModuleSortOrder, setEditModuleSortOrder] = useState("");
const [editModuleIcon, setEditModuleIcon] = useState(""); const [editModuleIcon, setEditModuleIcon] = useState("");
const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] = const [editModuleIconUploadBusy, setEditModuleIconUploadBusy] =
useState(false); useState(false);
@ -155,10 +165,21 @@ export function CourseDetailPage() {
useState<TopLevelCourseModuleItem | null>(null); useState<TopLevelCourseModuleItem | null>(null);
const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false); const [deletingModuleInFlight, setDeletingModuleInFlight] = useState(false);
const [activeTab, setActiveTab] = useState<"modules" | "practice">("modules");
const [practiceFilter, setPracticeFilter] = useState("All");
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
null,
);
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
number | null
>(null);
const openEditModule = (module: TopLevelCourseModuleItem) => { const openEditModule = (module: TopLevelCourseModuleItem) => {
setEditingModule(module); setEditingModule(module);
setEditModuleName(module.name ?? ""); setEditModuleName(module.name ?? "");
setEditModuleDescription(module.description ?? ""); setEditModuleSortOrder(String(module.sort_order ?? 0));
setEditModuleIcon(module.icon?.trim() ?? ""); setEditModuleIcon(module.icon?.trim() ?? "");
setEditModuleIconUploadBusy(false); setEditModuleIconUploadBusy(false);
}; };
@ -260,6 +281,91 @@ export function CourseDetailPage() {
void loadPage(); void loadPage();
}, [loadPage]); }, [loadPage]);
const loadCoursePractices = useCallback(async () => {
if (!Number.isFinite(courseIdNum) || courseIdNum < 1) {
setPractices([]);
setPracticesLoadError(null);
setPracticesLoading(false);
return;
}
setPracticesLoading(true);
setPracticesLoadError(null);
try {
const res = await getPracticesByParentCourse(courseIdNum, {
limit: 100,
offset: 0,
});
setPractices(unwrapPracticesList(res));
} catch {
setPractices([]);
setPracticesLoadError("Failed to load practices. Please try again.");
} finally {
setPracticesLoading(false);
}
}, [courseIdNum]);
useEffect(() => {
if (activeTab !== "practice") return;
void loadCoursePractices();
}, [activeTab, loadCoursePractices]);
const filteredPractices = useMemo(() => {
if (practiceFilter === "Published") {
return practices.filter(isPracticePublished);
}
if (practiceFilter === "Draft") {
return practices.filter(isPracticeDraft);
}
if (practiceFilter === "Archived") {
return [];
}
return practices;
}, [practices, practiceFilter]);
const handlePublishPractice = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
try {
await publishParentLinkedPractice(practiceId);
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
),
);
toast.success("Practice published");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to publish practice";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const handleSavePracticeAsDraft = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
try {
await updateParentLinkedPractice(practiceId, {
publish_status: "DRAFT",
});
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
),
);
toast.success("Practice saved as draft");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const handleSaveModuleEdit = async () => { const handleSaveModuleEdit = async () => {
if (!editingModule) return; if (!editingModule) return;
const name = editModuleName.trim(); const name = editModuleName.trim();
@ -267,12 +373,23 @@ export function CourseDetailPage() {
toast.error("Module name is required"); toast.error("Module name is required");
return; return;
} }
const sortOrderRaw = editModuleSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingModuleEdit(true); setSavingModuleEdit(true);
try { try {
await updateTopLevelCourseModule(editingModule.id, { await updateTopLevelCourseModule(editingModule.id, {
name, name,
description: editModuleDescription.trim(), description: editingModule.description?.trim() ?? "",
icon: editModuleIcon.trim(), icon: editModuleIcon.trim(),
sort_order,
}); });
toast.success("Module updated"); toast.success("Module updated");
setEditModuleIconUploadBusy(false); setEditModuleIconUploadBusy(false);
@ -380,20 +497,32 @@ export function CourseDetailPage() {
</Button> </Button>
</div> </div>
</div> </div>
<div className="relative"> <div className="border-b border-grayScale-200">
<div <div className="flex gap-10">
className="absolute inset-0 flex items-center" <button
aria-hidden="true" type="button"
> onClick={() => setActiveTab("modules")}
<div className="w-full border-t border-grayScale-200" /> className={cn(
</div> "pb-4 text-[16px] font-medium transition-all relative",
<div className="relative flex justify-center"> activeTab === "modules"
<div ? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
className="h-[0.5px] w-full rounded-full opacity-20" : "text-grayScale-400 hover:text-grayScale-600",
style={{ )}
background: "gray", >
}} Modules
/> </button>
<button
type="button"
onClick={() => setActiveTab("practice")}
className={cn(
"pb-4 text-[16px] font-medium transition-all relative",
activeTab === "practice"
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
: "text-grayScale-400 hover:text-grayScale-600",
)}
>
Practice
</button>
</div> </div>
</div> </div>
@ -412,18 +541,15 @@ export function CourseDetailPage() {
if (!open) closeEditModule(); if (!open) closeEditModule();
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader> <DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit module</DialogTitle> <DialogTitle>Edit module</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, description, and icon (upload or URL). Saved with{" "} Update name, sort order, and icon (upload or URL).
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /modules/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-2"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Name Name
@ -437,17 +563,27 @@ export function CourseDetailPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label
Description htmlFor="edit-module-sort-order"
className="text-sm font-medium text-grayScale-700"
>
Sort Order
</label> </label>
<Textarea <Input
value={editModuleDescription} id="edit-module-sort-order"
onChange={(e) => setEditModuleDescription(e.target.value)} type="number"
rows={4} min={0}
className="min-h-[100px] resize-y rounded-xl" step={1}
placeholder="Optional short description." inputMode="numeric"
disabled={savingModuleEdit} value={editModuleSortOrder}
onChange={(e) => setEditModuleSortOrder(e.target.value)}
className="rounded-xl"
placeholder="e.g. 5"
disabled={savingModuleEdit || editModuleIconUploadBusy}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when modules are listed.
</p>
</div> </div>
<ModuleIconUploadField <ModuleIconUploadField
value={editModuleIcon} value={editModuleIcon}
@ -456,7 +592,8 @@ export function CourseDetailPage() {
onUploadBusyChange={setEditModuleIconUploadBusy} onUploadBusyChange={setEditModuleIconUploadBusy}
/> />
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> </div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -477,97 +614,184 @@ export function CourseDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{modules.length === 0 ? ( {activeTab === "modules" ? (
<div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center"> modules.length === 0 ? (
<p className="text-sm font-medium text-grayScale-600"> <div className="rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center">
No modules in this course yet <p className="text-sm font-medium text-grayScale-600">
</p> No modules in this course yet
<p className="mt-1 text-sm text-grayScale-400"> </p>
Add modules when your workflow is connected, or create them via <p className="mt-1 text-sm text-grayScale-400">
the API. Add a module to organize lessons and practices for this course.
</p> </p>
</div> </div>
) : ( ) : (
<div <div
className="grid justify-start gap-10" className="grid justify-start gap-10"
style={{ style={{
gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))", gridTemplateColumns: "repeat(auto-fill, minmax(330px, 330px))",
}} }}
> >
{modules.map((module, index) => { {modules.map((module, index) => {
const iconSrc = module.icon?.trim() ?? ""; const iconSrc = module.icon?.trim() ?? "";
return ( return (
<Card <Card
key={module.id} key={module.id}
className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg" className="group relative flex h-full min-h-0 w-full flex-col overflow-hidden rounded-[16px] border border-grayScale-50 bg-white shadow-sm transition-all duration-300 hover:shadow-lg"
> >
<div className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto"> <div className="absolute right-2 top-2 z-10 flex translate-y-1 gap-1 opacity-0 pointer-events-none transition-all duration-300 ease-out group-hover:translate-y-0 group-hover:opacity-100 group-hover:pointer-events-auto">
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
size="icon" size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white" className="h-8 w-8 rounded-md bg-white/95 text-grayScale-600 shadow-sm transition-colors hover:bg-white"
aria-label={`Edit ${module.name}`} aria-label={`Edit ${module.name}`}
onClick={() => openEditModule(module)} onClick={() => openEditModule(module)}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
size="icon" size="icon"
className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50" className="h-8 w-8 rounded-md bg-white/95 text-red-600 shadow-sm transition-colors hover:bg-red-50"
aria-label={`Delete ${module.name}`} aria-label={`Delete ${module.name}`}
onClick={() => setDeletingModule(module)} onClick={() => setDeletingModule(module)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
<ModuleCardTopMedia iconSrc={iconSrc} /> <ModuleCardTopMedia iconSrc={iconSrc} />
<div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4"> <div className="flex min-h-0 flex-1 flex-col gap-6 p-2 pb-4 pt-4">
<div className="flex min-h-0 flex-1 gap-4"> <div className="flex min-h-0 flex-1 gap-4">
<ModuleIconCircle iconSrc={iconSrc} index={index} /> <ModuleIconCircle iconSrc={iconSrc} index={index} />
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<h3 className="text-lg font-bold tracking-tight text-[#0F172A]"> <h3 className="text-lg font-bold tracking-tight text-[#0F172A]">
{module.name} {module.name}
</h3> </h3>
<p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3"> <p className="text-[12px] font-medium leading-snug text-grayScale-400 line-clamp-3">
{module.description?.trim() {module.description?.trim()
? module.description ? module.description
: "—"} : "—"}
</p> </p>
</div>
</div>
<div className="mt-auto flex shrink-0 items-center gap-3">
<Button
variant="outline"
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all"
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`,
{
state: {
moduleName: module.name,
moduleDescription:
module.description?.trim() ?? "",
},
},
)
}
>
View Detail
</Button>
<PublishPracticeButton
parentKind="MODULE"
parentId={module.id}
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10 hover:bg-brand-600 disabled:opacity-60"
/>
</div> </div>
</div> </div>
</Card>
);
})}
</div>
)
) : (
<div className="space-y-8">
<div className="flex items-center gap-10 overflow-x-auto whitespace-nowrap rounded-2xl border border-grayScale-100 bg-white px-8 py-4 shadow-sm">
<div className="mr-2 flex items-center gap-2 text-[12px] font-bold uppercase tracking-widest text-grayScale-300">
STATUS:
</div>
<div className="flex items-center gap-3">
{["All", "Published", "Draft", "Archived"].map((label) => (
<button
key={label}
type="button"
onClick={() => setPracticeFilter(label)}
className={cn(
"h-9 rounded-full px-5 text-[13px] font-bold transition-all",
practiceFilter === label
? "bg-brand-500 text-white shadow-md shadow-brand-500/20"
: "bg-[#F1F5F9] text-grayScale-500 hover:bg-grayScale-100",
)}
>
{label}
</button>
))}
</div>
</div>
<div className="mt-auto flex shrink-0 items-center gap-3"> {practicesLoading ? (
<Button <div className="flex flex-col items-center justify-center py-24 text-[15px] font-medium text-grayScale-500">
variant="outline" Loading practices
className="h-10 flex-1 rounded-[6px] border-[#9E2891] text-sm text-[#9E2891] transition-all" </div>
onClick={() => ) : practicesLoadError ? (
navigate( <div className="mx-auto max-w-lg rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900">
`/new-content/learn-english/${programIdParam}/courses/${courseIdParam}/modules/${module.id}`, {practicesLoadError}
{ </div>
state: { ) : filteredPractices.length > 0 ? (
moduleName: module.name, <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
moduleDescription: {filteredPractices.map((practice) => (
module.description?.trim() ?? "", <ModulePracticeCard
}, key={practice.id}
}, practice={practice}
) statusUpdating={publishStatusPracticeId === practice.id}
} onEdit={() =>
> navigate(`/content/practices?type=course&id=${courseIdNum}`)
View Detail }
</Button> onPublish={() => void handlePublishPractice(practice.id)}
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-sm text-white shadow-md shadow-brand-500/10"> onSaveAsDraft={() =>
Publish Practice void handleSavePracticeAsDraft(practice.id)
</Button> }
</div> />
))}
</div>
) : (
<div className="mx-auto flex max-w-4xl flex-col items-center justify-center rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white px-4 py-32 shadow-sm">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-[#FAF5FF]">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-[#F5EBFF]">
<Calendar className="h-7 w-7 text-brand-500" />
</div> </div>
</Card> </div>
); <h2 className="mb-3 text-2xl font-extrabold text-grayScale-900">
})} {practices.length === 0
? "No practices for this course yet"
: "No practices match this filter"}
</h2>
<p className="mb-10 max-w-sm text-center text-[15px] font-medium leading-relaxed text-grayScale-400">
{practices.length === 0
? "Add a course-level practice to give learners exercises attached to this course."
: "Try another status filter or add a new practice."}
</p>
{practices.length === 0 ? (
<Button
variant="outline"
className="flex h-12 items-center gap-2 rounded-xl border-brand-500 px-8 font-bold text-brand-500 transition-all hover:bg-brand-50"
onClick={() =>
navigate(
`/new-content/learn-english/${programIdParam}/courses/add-practice?backTo=modules&courseId=${courseIdParam}`,
)
}
>
<Calendar className="h-5 w-5" />
Add Practice
</Button>
) : null}
</div>
)}
</div> </div>
)} )}

View File

@ -3,7 +3,6 @@ import { Link, useParams, useNavigate } from "react-router-dom";
import { import {
ArrowLeft, ArrowLeft,
Plus, Plus,
FileText,
LayoutGrid, LayoutGrid,
PlayCircle, PlayCircle,
ClipboardCheck, ClipboardCheck,
@ -15,7 +14,6 @@ import {
} from "lucide-react"; } 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 { Textarea } from "../../components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -45,7 +43,7 @@ export function CourseManagementPage() {
const catalogCourseId = Number(courseId); const catalogCourseId = Number(courseId);
const [addUnitOpen, setAddUnitOpen] = useState(false); const [addUnitOpen, setAddUnitOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState(""); const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [uploadingThumbnail, setUploadingThumbnail] = useState(false); const [uploadingThumbnail, setUploadingThumbnail] = useState(false);
@ -66,7 +64,6 @@ export function CourseManagementPage() {
const [unitsLoading, setUnitsLoading] = useState(false); const [unitsLoading, setUnitsLoading] = useState(false);
const [editingUnitId, setEditingUnitId] = useState<number | null>(null); const [editingUnitId, setEditingUnitId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1"); const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
@ -152,7 +149,7 @@ export function CourseManagementPage() {
const clearCreateUnitForm = () => { const clearCreateUnitForm = () => {
setCreateName(""); setCreateName("");
setCreateDescription(""); setCreateSortOrder("");
setCreateThumbnail(""); setCreateThumbnail("");
if (createThumbnailFileInputRef.current) { if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = ""; createThumbnailFileInputRef.current.value = "";
@ -202,13 +199,24 @@ export function CourseManagementPage() {
toast.error("Unit name is required"); toast.error("Unit name is required");
return; return;
} }
const sortOrderRaw = createSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setCreating(true); setCreating(true);
try { try {
const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail); const minioThumbnail = await resolveThumbnailToMinioUrl(createThumbnail);
const response = await createExamPrepCatalogUnit(catalogCourseId, { const response = await createExamPrepCatalogUnit(catalogCourseId, {
name, name,
description: createDescription.trim() || null, description: null,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
sort_order,
}); });
void response; void response;
await loadUnits(); await loadUnits();
@ -271,18 +279,16 @@ export function CourseManagementPage() {
const openEditUnit = (unit: (typeof units)[number]) => { const openEditUnit = (unit: (typeof units)[number]) => {
setEditingUnitId(unit.id); setEditingUnitId(unit.id);
setEditName(unit.name ?? ""); setEditName(unit.name ?? "");
setEditDescription(unit.description ?? "");
setEditThumbnail(unit.thumbnail ?? ""); setEditThumbnail(unit.thumbnail ?? "");
setEditSortOrder(String(unit.sortOrder ?? 1)); setEditSortOrder(String(unit.sortOrder ?? 0));
}; };
const closeEditUnit = () => { const closeEditUnit = () => {
if (savingEdit || uploadingEditThumbnail) return; if (savingEdit || uploadingEditThumbnail) return;
setEditingUnitId(null); setEditingUnitId(null);
setEditName(""); setEditName("");
setEditDescription("");
setEditThumbnail(""); setEditThumbnail("");
setEditSortOrder("1"); setEditSortOrder("");
}; };
const handleEditUnitThumbnailFile = async ( const handleEditUnitThumbnailFile = async (
@ -320,20 +326,30 @@ export function CourseManagementPage() {
toast.error("Unit name is required"); toast.error("Unit name is required");
return; return;
} }
const sortOrderNum = Number(editSortOrder); const sortOrderRaw = editSortOrder.trim();
if (!Number.isFinite(sortOrderNum) || sortOrderNum < 0) { if (!sortOrderRaw) {
toast.error("Sort order must be a valid number"); toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return; return;
} }
setSavingEdit(true); setSavingEdit(true);
try { try {
const existing = units.find((u) => u.id === editingUnitId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail); const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
await updateExamPrepCatalogUnit(editingUnitId, { await updateExamPrepCatalogUnit(editingUnitId, {
name, name,
description: editDescription.trim() || null, description: preservedDescription,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
sort_order: sortOrderNum, sort_order,
}); });
await loadUnits(); await loadUnits();
toast.success("Unit updated"); toast.success("Unit updated");
@ -425,18 +441,29 @@ export function CourseManagementPage() {
disabled={creating || uploadingThumbnail} disabled={creating || uploadingThumbnail}
/> />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800"> <label
Description htmlFor="create-unit-sort-order"
className="text-[15px] text-grayScale-800"
>
Sort Order
</label> </label>
<Textarea <Input
value={createDescription} id="create-unit-sort-order"
onChange={(e) => setCreateDescription(e.target.value)} type="number"
placeholder="Short unit description" min={0}
rows={4} step={1}
className="min-h-[96px] rounded-[8px] border-grayScale-400" inputMode="numeric"
value={createSortOrder}
onChange={(e) => setCreateSortOrder(e.target.value)}
placeholder="e.g. 0"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={creating || uploadingThumbnail} disabled={creating || uploadingThumbnail}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when units are listed.
</p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -528,17 +555,6 @@ export function CourseManagementPage() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(`/new-content/courses/${programType}/attach-practice`)
}
>
<FileText className="h-5 w-5" />
Attach Practice
</Button>
</div> </div>
</div> </div>
@ -690,25 +706,27 @@ export function CourseManagementPage() {
/> />
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label> <label
<Textarea htmlFor="edit-unit-sort-order"
value={editDescription} className="text-[15px] text-grayScale-800"
onChange={(e) => setEditDescription(e.target.value)} >
rows={4} Sort Order
className="min-h-[96px] rounded-[8px] border-grayScale-400" </label>
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input <Input
id="edit-unit-sort-order"
type="number" type="number"
min={0} min={0}
step={1}
inputMode="numeric"
value={editSortOrder} value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)} onChange={(e) => setEditSortOrder(e.target.value)}
className="h-12 border-grayScale-400 rounded-[8px] px-4" placeholder="e.g. 0"
className="h-12 border-grayScale-400 rounded-[8px] px-4 placeholder:text-grayScale-400 text-[15px] focus:ring-brand-500/20"
disabled={savingEdit || uploadingEditThumbnail} disabled={savingEdit || uploadingEditThumbnail}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when units are listed.
</p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label> <label className="text-[15px] text-grayScale-800">Thumbnail</label>

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2 } from "lucide-react"; import { ArrowLeft, Plus, FileText, Video } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@ -23,8 +23,18 @@ import {
updateExamPrepModuleLesson, updateExamPrepModuleLesson,
deleteExamPrepModuleLesson, deleteExamPrepModuleLesson,
getExamPrepModuleLessons, getExamPrepModuleLessons,
publishExamPrepModuleLesson,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile, uploadVideoFile } from "../../api/files.api"; import { uploadImageFile, uploadVideoFile } from "../../api/files.api";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import type { PracticePublishStatus } from "../../types/course.types";
const LESSON_THUMB_GRADIENTS = [
"from-[#CBD5E1] to-[#94A3B8]",
"from-[#DBEAFE] to-[#93C5FD]",
"from-[#FEF3C7] to-[#FCD34D]",
"from-[#FCE7F3] to-[#F9A8D4]",
] as const;
const MOCK_PRACTICES = [ const MOCK_PRACTICES = [
{ {
@ -60,12 +70,17 @@ export function CourseModuleDetailPage() {
id: number; id: number;
title: string; title: string;
videoUrl: string; videoUrl: string;
description: string; description: string | null;
thumbnail: string; thumbnail: string;
sortOrder: number; sortOrder: number;
gradient: string; publishStatus: PracticePublishStatus | string | null;
durationSeconds: number | null;
}> }>
>([]); >([]);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [createLessonOpen, setCreateLessonOpen] = useState(false); const [createLessonOpen, setCreateLessonOpen] = useState(false);
const [createTitle, setCreateTitle] = useState(""); const [createTitle, setCreateTitle] = useState("");
const [createVideoUrl, setCreateVideoUrl] = useState(""); const [createVideoUrl, setCreateVideoUrl] = useState("");
@ -121,6 +136,7 @@ export function CourseModuleDetailPage() {
return; return;
} }
setLessonsLoading(true); setLessonsLoading(true);
setLessonsLoadError(null);
try { try {
const response = await getExamPrepModuleLessons(parsedModuleId, { const response = await getExamPrepModuleLessons(parsedModuleId, {
limit: 20, limit: 20,
@ -129,24 +145,27 @@ export function CourseModuleDetailPage() {
const rows = response.data?.data?.lessons; const rows = response.data?.data?.lessons;
const list = Array.isArray(rows) ? rows : []; const list = Array.isArray(rows) ? rows : [];
setLessons( setLessons(
list.map((row, index) => ({ list.map((row) => {
id: Number(row.id), const raw = row.duration_seconds ?? row.duration ?? null;
title: row.title?.trim() || `Lesson ${row.id}`, const n =
videoUrl: row.video_url?.trim() || "", raw == null ? NaN : typeof raw === "number" ? raw : Number(raw);
description: row.description?.trim() || "—", const durationSeconds =
thumbnail: row.thumbnail?.trim() || "", Number.isFinite(n) && n > 0 ? n : null;
sortOrder: Number(row.sort_order ?? 0), return {
gradient: id: Number(row.id),
index % 3 === 1 title: row.title?.trim() || `Lesson ${row.id}`,
? "linear-gradient(135deg, rgba(79, 70, 229, 0.35) 0%, rgba(79, 70, 229, 0.6) 100%)" videoUrl: row.video_url?.trim() || "",
: index % 3 === 2 description: row.description?.trim() || null,
? "linear-gradient(135deg, rgba(124, 58, 237, 0.35) 0%, rgba(124, 58, 237, 0.6) 100%)" thumbnail: row.thumbnail?.trim() || "",
: "linear-gradient(135deg, rgba(158, 40, 145, 0.35) 0%, rgba(158, 40, 145, 0.6) 100%)", sortOrder: Number(row.sort_order ?? 0),
})), publishStatus: row.publish_status ?? null,
durationSeconds,
};
}),
); );
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Failed to load lessons"); setLessonsLoadError("Failed to load lessons. Please try again.");
setLessons([]); setLessons([]);
} finally { } finally {
setLessonsLoading(false); setLessonsLoading(false);
@ -252,7 +271,7 @@ export function CourseModuleDetailPage() {
} }
}; };
const handleCreateLesson = async () => { const handleCreateLesson = async (publishStatus: PracticePublishStatus) => {
if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) { if (!Number.isFinite(parsedModuleId) || parsedModuleId < 1) {
toast.error("Invalid module"); toast.error("Invalid module");
return; return;
@ -276,9 +295,14 @@ export function CourseModuleDetailPage() {
video_url: videoUrl, video_url: videoUrl,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
description: createDescription.trim() || null, description: createDescription.trim() || null,
publish_status: publishStatus,
}); });
await loadLessons(); await loadLessons();
toast.success("Lesson created"); toast.success(
publishStatus === "DRAFT"
? "Lesson saved as draft"
: "Lesson created",
);
clearCreateLessonForm(); clearCreateLessonForm();
setCreateLessonOpen(false); setCreateLessonOpen(false);
} catch (error: unknown) { } catch (error: unknown) {
@ -448,6 +472,45 @@ export function CourseModuleDetailPage() {
} }
}; };
const handleToggleLessonPublishStatus = async (
lessonId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusLessonId(lessonId);
try {
await publishExamPrepModuleLesson(lessonId, {
publish_status: nextStatus,
});
setLessons((prev) =>
prev.map((l) =>
l.id === lessonId ? { ...l, publishStatus: nextStatus } : l,
),
);
toast.success(
nextStatus === "PUBLISHED"
? "Lesson published"
: "Lesson saved as draft",
);
} catch (error: unknown) {
console.error(error);
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(nextStatus === "PUBLISHED"
? "Failed to publish lesson"
: "Failed to save lesson as draft");
toast.error(message);
} finally {
setPublishStatusLessonId(null);
}
};
const lessonAttachPracticePath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`;
const lessonPracticesPath = (lesson: (typeof lessons)[number]) =>
`/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title)}`;
return ( return (
<div className="space-y-8 animate-in fade-in duration-500 pb-10"> <div className="space-y-8 animate-in fade-in duration-500 pb-10">
{/* Navigation */} {/* Navigation */}
@ -476,12 +539,12 @@ export function CourseModuleDetailPage() {
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm" className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-sm"
onClick={() => onClick={() =>
navigate( navigate(
`/new-content/courses/${programType}/${courseId}/unit/${unitId}/module/${moduleId}/attach-practice`, `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice`,
) )
} }
> >
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
Attach Practice Add Practice
</Button> </Button>
<Dialog <Dialog
open={createLessonOpen} open={createLessonOpen}
@ -641,7 +704,7 @@ export function CourseModuleDetailPage() {
/> />
</div> </div>
</div> </div>
<div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex justify-end gap-3"> <div className="shrink-0 px-8 py-6 bg-grayScale-50/30 border-t border-grayScale-50 flex flex-wrap justify-end gap-3">
<DialogClose asChild> <DialogClose asChild>
<Button <Button
type="button" type="button"
@ -653,13 +716,22 @@ export function CourseModuleDetailPage() {
Cancel Cancel
</Button> </Button>
</DialogClose> </DialogClose>
<Button
type="button"
variant="outline"
className="h-11 px-8 rounded-[8px] border-grayScale-200 text-grayScale-700 font-bold hover:bg-grayScale-50"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson("DRAFT")}
>
{creatingLesson ? "Saving…" : "Save as draft"}
</Button>
<Button <Button
type="button" type="button"
className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600" className="h-11 px-8 rounded-[8px] bg-brand-500 text-white font-bold hover:bg-brand-600"
disabled={creatingLesson || uploadingThumbnail || uploadingVideo} disabled={creatingLesson || uploadingThumbnail || uploadingVideo}
onClick={() => void handleCreateLesson()} onClick={() => void handleCreateLesson("PUBLISHED")}
> >
{creatingLesson ? "Creating..." : "Create Lesson"} {creatingLesson ? "Creating..." : "Publish lesson"}
</Button> </Button>
</div> </div>
</div> </div>
@ -669,61 +741,101 @@ export function CourseModuleDetailPage() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-10 border-b border-grayScale-100"> <div className="border-b border-grayScale-200">
<button <div className="flex gap-10">
onClick={() => setActiveTab("video")} <button
className={cn( onClick={() => setActiveTab("video")}
"pb-4 text-[16px] font-bold transition-all relative px-2", className={cn(
activeTab === "video" "pb-4 text-[16px] font-medium transition-all relative",
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500" activeTab === "video"
: "text-grayScale-400 hover:text-grayScale-600", ? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
)} : "text-grayScale-400 hover:text-grayScale-600",
> )}
Lesson >
</button> Lesson
<button </button>
onClick={() => setActiveTab("practice")} <button
className={cn( onClick={() => setActiveTab("practice")}
"pb-4 text-[16px] font-bold transition-all relative px-2", className={cn(
activeTab === "practice" "pb-4 text-[16px] font-medium transition-all relative",
? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-brand-500" activeTab === "practice"
: "text-grayScale-400 hover:text-grayScale-600", ? "text-brand-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[3px] after:bg-brand-500 after:rounded-t-full"
)} : "text-grayScale-400 hover:text-grayScale-600",
> )}
Practice >
</button> Practice
</button>
</div>
</div> </div>
{/* Grid of Content */} {/* Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 pt-4"> <div className="mt-8">
{activeTab === "video" ? ( {activeTab === "video" ? (
lessonsLoading ? ( lessonsLoading ? (
<p className="text-sm text-grayScale-500">Loading lessons...</p> <div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
) : lessons.length === 0 ? ( Loading lessons
<div className="col-span-full rounded-xl border border-dashed border-grayScale-200 bg-grayScale-50/50 px-6 py-14 text-center"> </div>
<p className="text-sm font-medium text-grayScale-600"> ) : lessonsLoadError ? (
No lessons for this module yet <div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
</p> {lessonsLoadError}
<p className="mt-1 text-sm text-grayScale-400"> </div>
Create your first lesson to start building this module. ) : lessons.length > 0 ? (
</p> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{lessons.map((lesson, i) => (
<VideoCard
key={lesson.id}
id={lesson.id}
title={lesson.title}
videoUrl={lesson.videoUrl}
publishStatus={lesson.publishStatus}
hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={
LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]
}
durationSeconds={lesson.durationSeconds}
onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLessonId(lesson.id)}
description={lesson.description}
onAddPractice={() => navigate(lessonAttachPracticePath(lesson))}
onViewPractices={() => navigate(lessonPracticesPath(lesson))}
onTogglePublishStatus={(nextStatus) =>
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
}
publishStatusUpdating={publishStatusLessonId === lesson.id}
/>
))}
</div> </div>
) : ( ) : (
lessons.map((lesson) => ( <div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<VideoCard <div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
key={lesson.id} <div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
title={lesson.title} <Video className="h-7 w-7 text-brand-500 fill-brand-500/10" />
thumbnailUrl={lesson.thumbnail} </div>
videoUrl={lesson.videoUrl} </div>
thumbnailGradient={lesson.gradient} <h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
hoverModuleActions No lessons in this module yet
onEdit={() => openEditLesson(lesson)} </h2>
onDelete={() => setDeletingLessonId(lesson.id)} <p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
/> Lessons are a great way to engage students. Add your first
)) lesson to get started.
</p>
<Button
variant="outline"
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() => setCreateLessonOpen(true)}
>
<Video className="h-5 w-5" />
Add Lesson
</Button>
</div>
) )
) : ( ) : (
MOCK_PRACTICES.map((item) => <PracticeCard key={item.id} {...item} />) <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{MOCK_PRACTICES.map((item) => (
<PracticeCard key={item.id} {...item} />
))}
</div>
)} )}
</div> </div>

View File

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

View File

@ -1,31 +1,281 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom"
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react"
import { Button } from "../../components/ui/button"; import { toast } from "sonner"
import { Stepper } from "../../components/ui/stepper"; import { Button } from "../../components/ui/button"
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep"; import { Card } from "../../components/ui/card"
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"; import { Stepper } from "../../components/ui/stepper"
import {
createQuestionTypeDefinition,
validateQuestionTypeDefinition,
extractDefinitionMutationId,
getQuestionComponentCatalog,
getQuestionTypeDefinitionById,
updateQuestionTypeDefinition,
} from "../../api/questionTypeDefinitions.api"
import type {
QuestionComponentCatalog,
QuestionTypeDefinition,
QuestionTypeDefinitionCreatePayload,
} from "../../types/questionTypeDefinition.types"
import {
buildCreatePayload,
buildValidateKindsPayload,
validateDefinitionBasic,
validateDefinitionKinds,
validateDefinitionSchemas,
type FieldErrorMap,
} from "./lib/questionTypeDefinitionValidation"
import { QuestionTypeBasicInfoStep } from "./components/question-type-steps/QuestionTypeBasicInfoStep"
import { QuestionTypeConfigStep } from "./components/question-type-steps/QuestionTypeConfigStep"
import { QuestionTypeValidatePreviewStep } from "./components/question-type-steps/QuestionTypeValidatePreviewStep"
import { QuestionTypeReviewPublishStep } from "./components/question-type-steps/QuestionTypeReviewPublishStep"
import { defaultLabelForKind } from "../../lib/schemaSlotLabel"
const initialDraft = (): QuestionTypeDefinitionCreatePayload => ({
key: "",
display_name: "",
description: "",
status: "ACTIVE",
stimulus_component_kinds: [],
response_component_kinds: [],
stimulus_schema: [],
response_schema: [],
})
function seedSchemaFromKinds(kinds: string[]) {
return kinds.map((k, i) => ({
id: `${(k || "field").toLowerCase().replace(/[^a-z0-9]+/g, "_") || "field"}_${i + 1}`,
kind: k,
label: defaultLabelForKind(k),
required: true as boolean,
}))
}
function definitionToDraft(def: QuestionTypeDefinition): QuestionTypeDefinitionCreatePayload {
return {
key: def.key,
display_name: def.display_name,
description: def.description ?? "",
status: def.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
stimulus_component_kinds: [...(def.stimulus_component_kinds ?? [])],
response_component_kinds: [...(def.response_component_kinds ?? [])],
stimulus_schema: (def.stimulus_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
response_schema: (def.response_schema ?? []).map((r) => ({
...r,
label: r.label?.trim() || defaultLabelForKind(r.kind),
})),
}
}
export function CreateQuestionTypeFlow() { export function CreateQuestionTypeFlow() {
const navigate = useNavigate(); const navigate = useNavigate()
const [currentStep, setCurrentStep] = useState(1); const { definitionId: definitionIdParam } = useParams<{ definitionId?: string }>()
const editDefinitionId = useMemo(() => {
if (!definitionIdParam || !/^\d+$/.test(definitionIdParam)) return null
const n = Number(definitionIdParam)
return Number.isFinite(n) && n > 0 ? n : null
}, [definitionIdParam])
const isEdit = editDefinitionId != null
const steps = [ const [currentStep, setCurrentStep] = useState(1)
"Basic Info", const [draft, setDraft] = useState<QuestionTypeDefinitionCreatePayload>(initialDraft)
"Input & Answer Configuration", const [stepErrors, setStepErrors] = useState<FieldErrorMap>({})
"Versions", const [definitionReady, setDefinitionReady] = useState(!isEdit)
"Review & Publish", const [isSystemDefinition, setIsSystemDefinition] = useState(false)
];
const handleNext = () => const [componentCatalog, setComponentCatalog] = useState<QuestionComponentCatalog>({
setCurrentStep((prev) => Math.min(prev + 1, steps.length)); stimulus_component_kinds: [],
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1)); response_component_kinds: [],
})
const [catalogLoading, setCatalogLoading] = useState(true)
const [catalogError, setCatalogError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
;(async () => {
setCatalogLoading(true)
setCatalogError(null)
try {
const cat = await getQuestionComponentCatalog()
if (!cancelled) {
setComponentCatalog(cat)
if (
!cat.stimulus_component_kinds.length &&
!cat.response_component_kinds.length
) {
setCatalogError("Catalog returned no kinds — check API response shape.")
}
}
} catch (e) {
if (!cancelled) {
console.error(e)
setCatalogError("Failed to load component catalog.")
toast.error("Failed to load component catalog")
}
} finally {
if (!cancelled) setCatalogLoading(false)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!isEdit || editDefinitionId == null) {
setDefinitionReady(true)
return
}
let cancelled = false
setDefinitionReady(false)
;(async () => {
try {
const def = await getQuestionTypeDefinitionById(editDefinitionId)
if (cancelled) return
if (!def) {
toast.error("Definition not found")
navigate("/new-content/question-types")
return
}
setDraft(definitionToDraft(def))
setIsSystemDefinition(Boolean(def.is_system))
setCurrentStep(1)
setStepErrors({})
} catch (e) {
if (!cancelled) {
console.error(e)
toast.error("Failed to load definition")
navigate("/new-content/question-types")
}
} finally {
if (!cancelled) setDefinitionReady(true)
}
})()
return () => {
cancelled = true
}
}, [isEdit, editDefinitionId, navigate])
useEffect(() => {
if (!isEdit) {
setDraft(initialDraft())
setCurrentStep(1)
setStepErrors({})
setDefinitionReady(true)
}
}, [isEdit])
const catalogForSchemaValidation = {
stimulus: new Set(componentCatalog.stimulus_component_kinds),
response: new Set(componentCatalog.response_component_kinds),
}
const handleNextFromStep1 = () => {
const e1 = validateDefinitionBasic(draft)
setStepErrors(e1)
if (Object.keys(e1).length) {
toast.error("Fix the highlighted fields before continuing.")
return
}
setStepErrors({})
setCurrentStep(2)
}
const handleNextFromStep2 = () => {
const eKinds = validateDefinitionKinds(draft, componentCatalog)
setStepErrors(eKinds)
if (Object.keys(eKinds).length) {
toast.error("Select valid stimulus and response component kinds.")
return
}
const nextDraft: QuestionTypeDefinitionCreatePayload = { ...draft }
if (!nextDraft.stimulus_schema.length && nextDraft.stimulus_component_kinds.length) {
nextDraft.stimulus_schema = seedSchemaFromKinds(nextDraft.stimulus_component_kinds)
}
if (!nextDraft.response_schema.length && nextDraft.response_component_kinds.length) {
nextDraft.response_schema = seedSchemaFromKinds(nextDraft.response_component_kinds)
}
setDraft(nextDraft)
const mergedSchema = validateDefinitionSchemas(nextDraft, catalogForSchemaValidation)
setStepErrors(mergedSchema)
if (Object.keys(mergedSchema).length) {
toast.error("Fix schema issues (expand Advanced) or adjust selected kinds.", {
description: "Open “Advanced: edit schema rows” to fix row ids and kinds.",
})
return
}
setStepErrors({})
setCurrentStep(3)
}
const handleBack = () => setCurrentStep((prev) => Math.max(prev - 1, 1))
const handleHeaderSaveDraft = async () => {
setDraft((d) => ({ ...d, status: "INACTIVE" }))
if (currentStep < 4) {
toast.message("Status set to Inactive", {
description: "Complete the wizard; on the last step you can save the definition to the server.",
})
return
}
const body = { ...buildCreatePayload({ ...draft, status: "INACTIVE" }), status: "INACTIVE" as const }
try {
if (isEdit && editDefinitionId != null) {
const res = await updateQuestionTypeDefinition(editDefinitionId, body)
const id = extractDefinitionMutationId(res) ?? editDefinitionId
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?updated=${id}`)
return
}
const validation = await validateQuestionTypeDefinition(buildValidateKindsPayload(body))
if (!validation.valid) {
toast.error(validation.message || "Invalid question type definition", {
description: validation.error ? String(validation.error) : undefined,
})
return
}
const res = await createQuestionTypeDefinition(body)
const id = extractDefinitionMutationId(res)
if (id == null) {
toast.error(res.data?.message ?? "Save failed: missing definition id in response.")
return
}
toast.success(res.data?.message || "Definition saved as draft", {
description: `Definition id: ${id}`,
})
navigate(`/new-content/question-types?created=${id}`)
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string; error?: string } } }
toast.error(String(err.response?.data?.message || "Save failed"), {
description: err.response?.data?.error ? String(err.response.data.error) : undefined,
})
}
}
const steps = ["Basic Info", "Input & answer types", "Validate", "Review & publish"]
if (isEdit && !definitionReady) {
return (
<div className="min-h-screen pb-20 flex items-center justify-center px-6">
<Card className="p-10 max-w-md w-full text-center border-grayScale-200">
<p className="text-grayScale-600 font-medium">Loading definition</p>
</Card>
</div>
)
}
return ( return (
<div className="min-h-screen pb-20 overflow-x-hidden"> <div className="min-h-screen pb-20 overflow-x-hidden">
{/* Header */} <div className=" border-b border-grayScale-100 sticky top-0 z-50 bg-white/95 backdrop-blur">
<div className=" border-b border-grayScale-100 sticky top-0 z-50"> <div className="max-w-[1440px] mx-auto py-6 px-4 sm:px-6">
<div className="max-w-[1440px] mx-auto py-6">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<Link <Link
to="/new-content/question-types" to="/new-content/question-types"
@ -36,16 +286,18 @@ export function CreateQuestionTypeFlow() {
</Link> </Link>
</div> </div>
<div className="flex items-start justify-between"> <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight"> <h1 className="text-[28px] font-bold text-grayScale-900 tracking-tight">
Create Question Type {isEdit ? "Edit question type definition" : "Create question type definition"}
</h1> </h1>
<p className="text-grayScale-500 text-[14px] font-medium"> <p className="text-grayScale-500 text-[14px] font-medium max-w-2xl">
Create a new immersive practice session for students. {isEdit
? `Update reusable question type definition #${editDefinitionId}.`
: "Build a reusable question type template for dynamic practice and assessment questions."}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4 shrink-0">
<Button <Button
variant="outline" variant="outline"
className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50" className="h-10 px-8 rounded-[6px] border-grayScale-200 text-grayScale-900 font-medium hover:bg-grayScale-50"
@ -53,7 +305,10 @@ export function CreateQuestionTypeFlow() {
> >
Cancel Cancel
</Button> </Button>
<Button className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all"> <Button
className="h-10 px-8 rounded-[6px] bg-[#9E2891] font-medium text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all"
onClick={handleHeaderSaveDraft}
>
Save as Draft Save as Draft
</Button> </Button>
</div> </div>
@ -65,23 +320,41 @@ export function CreateQuestionTypeFlow() {
</div> </div>
</div> </div>
{/* Main Content */} <div className="max-w-[1440px] mx-auto px-4 sm:px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="max-w-[1440px] mx-auto px-10 mt-12 animate-in fade-in slide-in-from-bottom-4 duration-500"> {currentStep === 1 && (
{currentStep === 1 && <QuestionTypeBasicInfoStep onNext={handleNext} />} <QuestionTypeBasicInfoStep
{currentStep === 2 && ( draft={draft}
<QuestionTypeConfigStep onNext={handleNext} onBack={handleBack} /> setDraft={setDraft}
errors={stepErrors}
keyReadOnly={isEdit}
onNext={handleNextFromStep1}
/>
)} )}
{currentStep > 2 && ( {currentStep === 2 && (
<div className="bg-white rounded-2xl p-12 text-center border border-grayScale-100 shadow-sm"> <QuestionTypeConfigStep
<p className="text-grayScale-400 font-medium"> draft={draft}
Step {currentStep} implementation in progress... setDraft={setDraft}
</p> stimulusCatalogKinds={componentCatalog.stimulus_component_kinds}
<Button onClick={handleBack} variant="outline" className="mt-4"> responseCatalogKinds={componentCatalog.response_component_kinds}
Go Back catalogLoading={catalogLoading}
</Button> catalogError={catalogError}
</div> errors={stepErrors}
onNext={handleNextFromStep2}
onBack={handleBack}
/>
)}
{currentStep === 3 && (
<QuestionTypeValidatePreviewStep draft={draft} onNext={() => setCurrentStep(4)} onBack={handleBack} />
)}
{currentStep === 4 && (
<QuestionTypeReviewPublishStep
draft={draft}
onBack={handleBack}
editDefinitionId={editDefinitionId}
isSystem={isSystemDefinition}
/>
)} )}
</div> </div>
</div> </div>
); )
} }

View File

@ -9,7 +9,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { Select } from "../../components/ui/select" import { Select } from "../../components/ui/select"
import { SpinnerIcon } from "../../components/ui/spinner-icon" import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { Textarea } from "../../components/ui/textarea"
import { import {
createModule, createModule,
deleteModule, deleteModule,
@ -241,7 +240,6 @@ export function HumanLanguageHierarchyPage() {
const [createModuleTitle, setCreateModuleTitle] = useState("") const [createModuleTitle, setCreateModuleTitle] = useState("")
const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false) const [createModuleUseDefaultNaming, setCreateModuleUseDefaultNaming] = useState(false)
const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("") const [createModuleDefaultTitle, setCreateModuleDefaultTitle] = useState("")
const [createModuleDescription, setCreateModuleDescription] = useState("")
const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url") const [createModuleIconSource, setCreateModuleIconSource] = useState<"url" | "file">("url")
const [createModuleIconUrl, setCreateModuleIconUrl] = useState("") const [createModuleIconUrl, setCreateModuleIconUrl] = useState("")
const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null) const [createModuleIconFile, setCreateModuleIconFile] = useState<File | null>(null)
@ -253,7 +251,6 @@ export function HumanLanguageHierarchyPage() {
const [editModuleSaving, setEditModuleSaving] = useState(false) const [editModuleSaving, setEditModuleSaving] = useState(false)
const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null) const [editModuleTarget, setEditModuleTarget] = useState<EditModuleTarget | null>(null)
const [editModuleTitle, setEditModuleTitle] = useState("") const [editModuleTitle, setEditModuleTitle] = useState("")
const [editModuleDescription, setEditModuleDescription] = useState("")
const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0) const [editModuleDisplayOrder, setEditModuleDisplayOrder] = useState(0)
const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url") const [editModuleIconSource, setEditModuleIconSource] = useState<"url" | "file">("url")
const [editModuleIconUrl, setEditModuleIconUrl] = useState("") const [editModuleIconUrl, setEditModuleIconUrl] = useState("")
@ -467,7 +464,6 @@ export function HumanLanguageHierarchyPage() {
setCreateModuleUseDefaultNaming(false) setCreateModuleUseDefaultNaming(false)
setCreateModuleDefaultTitle(getNextDefaultModuleName(level)) setCreateModuleDefaultTitle(getNextDefaultModuleName(level))
setCreateModuleTitle("") setCreateModuleTitle("")
setCreateModuleDescription("")
setCreateModuleIconSource("url") setCreateModuleIconSource("url")
setCreateModuleIconUrl("") setCreateModuleIconUrl("")
setCreateModuleIconFile(null) setCreateModuleIconFile(null)
@ -503,7 +499,6 @@ export function HumanLanguageHierarchyPage() {
await createModule({ await createModule({
level_id: createModuleLevelId, level_id: createModuleLevelId,
title, title,
description: createModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl, icon_url: uploadedIconUrl,
display_order: createModuleDisplayOrder, display_order: createModuleDisplayOrder,
is_active: true, is_active: true,
@ -553,7 +548,6 @@ export function HumanLanguageHierarchyPage() {
levelKey, levelKey,
}) })
setEditModuleTitle(module.title) setEditModuleTitle(module.title)
setEditModuleDescription("")
setEditModuleDisplayOrder(moduleDisplayOrder) setEditModuleDisplayOrder(moduleDisplayOrder)
setEditModuleIconSource("url") setEditModuleIconSource("url")
setEditModuleIconUrl(existingIconUrl) setEditModuleIconUrl(existingIconUrl)
@ -594,7 +588,6 @@ export function HumanLanguageHierarchyPage() {
await updateModule(editModuleTarget.moduleId, { await updateModule(editModuleTarget.moduleId, {
title, title,
description: editModuleDescription.trim() || undefined,
icon_url: uploadedIconUrl, icon_url: uploadedIconUrl,
display_order: editModuleDisplayOrder, display_order: editModuleDisplayOrder,
is_active: true, is_active: true,
@ -794,7 +787,7 @@ export function HumanLanguageHierarchyPage() {
<div> <div>
<p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p> <p className="text-sm font-medium text-grayScale-800">Select a sub-category to start managing hierarchy</p>
<p className="mt-1 text-sm text-grayScale-500"> <p className="mt-1 text-sm text-grayScale-500">
Powered by `GET /course-management/human-language/hierarchy` and `GET /course-management/courses/:courseId/hierarchy`. Choose a sub-category from the list to view and manage its course structure.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -1026,7 +1019,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Create module</DialogTitle> <DialogTitle>Create module</DialogTitle>
<DialogDescription> <DialogDescription>
Add a module to this level. This will call `POST /course-management/modules`. Add a module to this level.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -1068,17 +1061,6 @@ export function HumanLanguageHierarchyPage() {
/> />
</div> </div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description (optional)</label>
<Textarea
rows={3}
value={createModuleDescription}
onChange={(event) => setCreateModuleDescription(event.target.value)}
placeholder="Optional description"
disabled={createModuleSaving}
/>
</div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label> <label className="mb-1.5 block text-sm font-medium text-grayScale-600">Icon URL (optional)</label>
<div className="mb-2 grid grid-cols-2 gap-2"> <div className="mb-2 grid grid-cols-2 gap-2">
@ -1158,7 +1140,7 @@ export function HumanLanguageHierarchyPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Update module</DialogTitle> <DialogTitle>Update module</DialogTitle>
<DialogDescription> <DialogDescription>
Update this module using `PUT /course-management/modules/:moduleId`. Update this module&apos;s name, order, and settings.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -1173,17 +1155,6 @@ export function HumanLanguageHierarchyPage() {
/> />
</div> </div>
<div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Description</label>
<Textarea
rows={3}
value={editModuleDescription}
onChange={(event) => setEditModuleDescription(event.target.value)}
placeholder="New description"
disabled={editModuleSaving}
/>
</div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label> <label className="mb-1.5 block text-sm font-medium text-grayScale-600">Display order</label>
<Input <Input

View File

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

View File

@ -24,9 +24,26 @@ import {
updateLearningProgram, updateLearningProgram,
deleteLearningProgram, deleteLearningProgram,
} from "../../api/courses.api"; } from "../../api/courses.api";
import { uploadImageFile } from "../../api/files.api"; import { refreshFileUrl, uploadImageFile } from "../../api/files.api";
import type { LearningProgramListItem } from "../../types/course.types"; import type { LearningProgramListItem } from "../../types/course.types";
/** Presigned MinIO/S3 URLs and our storage hosts — safe to send to POST /files/refresh-url. */
function looksLikeRefreshableFileUrl(url: string): boolean {
const trimmed = url.trim();
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) return false;
try {
const u = new URL(trimmed);
const q = u.search.toLowerCase();
if (q.includes("x-amz-")) return true;
const h = u.hostname.toLowerCase();
if (h.includes("yimaruacademy.com")) return true;
if (h.includes("minio")) return true;
return false;
} catch {
return false;
}
}
export function LearnEnglishPage() { export function LearnEnglishPage() {
const [programs, setPrograms] = useState<LearningProgramListItem[]>([]); const [programs, setPrograms] = useState<LearningProgramListItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -36,6 +53,7 @@ export function LearnEnglishPage() {
useState<LearningProgramListItem | null>(null); useState<LearningProgramListItem | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState(""); const [editDescription, setEditDescription] = useState("");
const [editSortOrder, setEditSortOrder] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false); const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
@ -44,6 +62,7 @@ export function LearnEnglishPage() {
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState(""); const [createDescription, setCreateDescription] = useState("");
const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [createSaving, setCreateSaving] = useState(false); const [createSaving, setCreateSaving] = useState(false);
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false); const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
@ -57,6 +76,7 @@ export function LearnEnglishPage() {
setEditingProgram(program); setEditingProgram(program);
setEditName(program.name ?? ""); setEditName(program.name ?? "");
setEditDescription(program.description?.trim() ?? ""); setEditDescription(program.description?.trim() ?? "");
setEditSortOrder(String(program.sort_order ?? 0));
setEditThumbnail(program.thumbnail?.trim() ?? ""); setEditThumbnail(program.thumbnail?.trim() ?? "");
}; };
@ -64,6 +84,7 @@ export function LearnEnglishPage() {
setEditingProgram(null); setEditingProgram(null);
setEditName(""); setEditName("");
setEditDescription(""); setEditDescription("");
setEditSortOrder("");
setEditThumbnail(""); setEditThumbnail("");
setUploadingEditThumbnail(false); setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = ""; if (editThumbnailFileInputRef.current) editThumbnailFileInputRef.current.value = "";
@ -107,6 +128,7 @@ export function LearnEnglishPage() {
const clearCreateFormFields = () => { const clearCreateFormFields = () => {
setCreateName(""); setCreateName("");
setCreateDescription(""); setCreateDescription("");
setCreateSortOrder("");
setCreateThumbnail(""); setCreateThumbnail("");
if (createThumbnailFileInputRef.current) { if (createThumbnailFileInputRef.current) {
createThumbnailFileInputRef.current.value = ""; createThumbnailFileInputRef.current.value = "";
@ -160,12 +182,23 @@ export function LearnEnglishPage() {
toast.error("Program name is required"); toast.error("Program name is required");
return; return;
} }
const sortOrderRaw = createSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setCreateSaving(true); setCreateSaving(true);
try { try {
await createLearningProgram({ await createLearningProgram({
name, name,
description: createDescription.trim(), description: createDescription.trim(),
thumbnail: createThumbnail.trim(), thumbnail: createThumbnail.trim(),
sort_order,
}); });
toast.success("Program created"); toast.success("Program created");
clearCreateFormFields(); clearCreateFormFields();
@ -189,12 +222,23 @@ export function LearnEnglishPage() {
toast.error("Program name is required"); toast.error("Program name is required");
return; return;
} }
const sortOrderRaw = editSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingEdit(true); setSavingEdit(true);
try { try {
await updateLearningProgram(editingProgram.id, { await updateLearningProgram(editingProgram.id, {
name, name,
description: editDescription.trim(), description: editDescription.trim(),
thumbnail: editThumbnail.trim(), thumbnail: editThumbnail.trim(),
sort_order,
}); });
toast.success("Program updated"); toast.success("Program updated");
closeEdit(); closeEdit();
@ -240,6 +284,35 @@ export function LearnEnglishPage() {
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0), (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0),
); );
setPrograms(sorted); setPrograms(sorted);
void (async () => {
const results = await Promise.all(
sorted.map(async (p) => {
const ref = p.thumbnail?.trim();
if (!ref || !looksLikeRefreshableFileUrl(ref)) return null;
try {
const res = await refreshFileUrl(ref);
const url = res.data?.data?.url?.trim();
if (!url) return null;
return { id: p.id, url };
} catch {
return null;
}
}),
);
const map = new Map(
results
.filter((r): r is { id: number; url: string } => r != null)
.map((r) => [r.id, r.url] as const),
);
if (map.size === 0) return;
setPrograms((prev) =>
prev.map((prog) => {
const next = map.get(prog.id);
return next ? { ...prog, thumbnail: next } : prog;
}),
);
})();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setError("Failed to load programs"); setError("Failed to load programs");
@ -283,15 +356,8 @@ export function LearnEnglishPage() {
Add New Program Add New Program
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a learning program via{" "} Create a new learning program. Add a thumbnail as an image URL or by uploading a
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600"> file.
POST /programs
</code>
. Thumbnail can be a URL or a file uploaded through{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Gradient Divider */} {/* Gradient Divider */}
@ -348,6 +414,27 @@ export function LearnEnglishPage() {
/> />
</div> </div>
<div className="space-y-2">
<label htmlFor="create-program-sort-order" className="text-[15px] text-grayScale-700">
Sort Order
</label>
<Input
id="create-program-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={createSortOrder}
onChange={(e) => setCreateSortOrder(e.target.value)}
placeholder="e.g. 5"
className="h-12 rounded-xl ring-0"
disabled={createSaving || createUploadingThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when programs are listed.
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[15px] text-grayScale-700"> <label className="text-[15px] text-grayScale-700">
Thumbnail Thumbnail
@ -549,16 +636,17 @@ export function LearnEnglishPage() {
if (!open) closeEdit(); if (!open) closeEdit();
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader> <DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit program</DialogTitle> <DialogTitle>Edit program</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, description, and thumbnail. Upload an image from your Update name, description, sort order, and thumbnail. Upload an image
computer (via file storage) or paste a URL. Changes are saved to the from your computer (via file storage) or paste a URL. Changes are
server. saved to the server.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-2"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Name Name
@ -584,6 +672,26 @@ export function LearnEnglishPage() {
disabled={savingEdit || uploadingEditThumbnail} disabled={savingEdit || uploadingEditThumbnail}
/> />
</div> </div>
<div className="space-y-2">
<label htmlFor="edit-program-sort-order" className="text-sm font-medium text-grayScale-700">
Sort Order
</label>
<Input
id="edit-program-sort-order"
type="number"
min={0}
step={1}
inputMode="numeric"
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="rounded-xl"
placeholder="e.g. 5"
disabled={savingEdit || uploadingEditThumbnail}
/>
<p className="text-xs text-grayScale-500">
Lower numbers appear first when programs are listed.
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Thumbnail Thumbnail
@ -624,15 +732,12 @@ export function LearnEnglishPage() {
disabled={savingEdit || uploadingEditThumbnail} disabled={savingEdit || uploadingEditThumbnail}
/> />
<p className="text-xs text-grayScale-500"> <p className="text-xs text-grayScale-500">
Local images are sent to{" "} Uploaded images are stored and used as the program thumbnail.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the returned URL is stored as the program thumbnail.
</p> </p>
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> </div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -0,0 +1,380 @@
import { useCallback, useEffect, useState } from "react";
import {
AlertCircle,
ArrowLeft,
BookOpen,
Calendar,
Clock,
Hash,
Loader2,
RefreshCw,
Sparkles,
} from "lucide-react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { getPracticesByParentLesson } from "../../api/courses.api";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import type {
GetPracticesByParentContextResponse,
ParentContextPractice,
} from "../../types/course.types";
import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils";
function unwrapPracticesEnvelope(
res: { data?: GetPracticesByParentContextResponse & { Data?: GetPracticesByParentContextResponse["data"] } },
): GetPracticesByParentContextResponse["data"] | null {
const b = res.data;
if (!b) return null;
return b.data ?? b.Data ?? null;
}
function formatPracticeDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? iso
: d.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
function PracticeCard({
practice,
index,
total,
}: {
practice: ParentContextPractice;
index: number;
total: number;
}) {
const [imgFailed, setImgFailed] = useState(false);
const thumb = resolveThumbnailForPreview(practice.story_image);
const showThumb = Boolean(thumb) && !imgFailed;
return (
<Card
className={cn(
"overflow-hidden border-grayScale-100/90 bg-white shadow-sm transition-all duration-300",
"hover:border-brand-200/60 hover:shadow-md hover:shadow-brand-500/5",
)}
>
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row lg:items-stretch">
<div className="relative shrink-0 lg:w-[280px]">
<div
className={cn(
"relative aspect-[16/10] w-full overflow-hidden bg-gradient-to-br from-grayScale-100 to-grayScale-50 lg:aspect-auto lg:h-full lg:min-h-[220px]",
!showThumb && "grid min-h-[180px] place-items-center lg:min-h-[220px]",
)}
>
{showThumb ? (
<>
<img
src={thumb!}
alt=""
className="h-full w-full object-cover"
onError={() => setImgFailed(true)}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/25 via-transparent to-black/10" />
</>
) : (
<div className="flex flex-col items-center gap-2 text-grayScale-400">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/80 shadow-inner ring-1 ring-grayScale-200/80">
<BookOpen className="h-7 w-7" />
</div>
<span className="text-[11px] font-semibold uppercase tracking-wider">
No cover image
</span>
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
Practice {index + 1} of {total}
</span>
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
ID {practice.id}
</Badge>
</div>
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
{practice.title}
</h2>
{practice.story_description?.trim() ? (
<div className="mt-4 rounded-xl border border-grayScale-100 bg-grayScale-50/80 px-4 py-3.5">
<p className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
Story & instructions
</p>
<p className="mt-2 whitespace-pre-line text-[14px] leading-relaxed text-grayScale-700">
{practice.story_description}
</p>
</div>
) : null}
{practice.quick_tips?.trim() ? (
<div className="mt-4 border-l-[3px] border-amber-400 bg-gradient-to-r from-amber-50/90 to-amber-50/30 py-3 pl-4 pr-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-amber-900/75">
Quick tips
</p>
<p className="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-grayScale-800">
{practice.quick_tips}
</p>
</div>
) : null}
<div className="mt-6 flex flex-wrap gap-2 border-t border-grayScale-100 pt-5">
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Hash className="h-3 w-3 opacity-70" aria-hidden />
Question set {practice.question_set_id}
</Badge>
<Badge variant="secondary" className="gap-1.5 pl-2 pr-2.5 py-1 font-medium normal-case">
<Clock className="h-3 w-3 opacity-70" aria-hidden />
{formatPracticeDate(practice.created_at)}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
export function LessonPracticesPage() {
const navigate = useNavigate();
const { level, programType, courseId, unitId, moduleId, lessonId } = useParams<{
level?: string;
programType?: string;
courseId?: string;
unitId?: string;
moduleId?: string;
lessonId?: string;
}>();
const [searchParams] = useSearchParams();
const lessonTitle = searchParams.get("lessonTitle")?.trim() || "";
const isExamPrep = Boolean(programType?.trim());
const backHref = isExamPrep
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}`
: `/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}`;
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const lid = lessonId ? Number(lessonId) : NaN;
const validLesson = Number.isFinite(lid) && lid > 0;
const load = useCallback(async () => {
if (!validLesson) {
setLoading(false);
setLoadError("Invalid lesson.");
setPractices([]);
return;
}
setLoading(true);
setLoadError(null);
try {
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
const envelope = unwrapPracticesEnvelope(res);
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
setPractices(list);
setTotalCount(
typeof envelope?.total_count === "number"
? envelope.total_count
: list.length,
);
} catch {
setPractices([]);
setTotalCount(0);
setLoadError("Could not load practices for this lesson.");
toast.error("Failed to load practices");
} finally {
setLoading(false);
}
}, [lid, validLesson]);
useEffect(() => {
void load();
}, [load]);
const displayTitle =
lessonTitle || (validLesson ? `Lesson #${lid}` : "Lesson practices");
const addPracticeHref = isExamPrep
? `/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)}`;
return (
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full bg-brand-500/[0.06] blur-3xl" />
<div className="absolute -bottom-32 -left-20 h-80 w-80 rounded-full bg-violet-500/[0.05] blur-3xl" />
</div>
<div className="mx-auto max-w-4xl px-4 pb-24 pt-8 sm:px-6 lg:px-8">
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500">
<Link
to={backHref}
className="group mb-6 inline-flex items-center gap-2 rounded-full border border-transparent px-1 py-1 text-[14px] font-medium text-grayScale-600 transition-colors hover:border-grayScale-200 hover:bg-white/80 hover:text-brand-600"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-grayScale-100 transition-transform group-hover:-translate-x-0.5">
<ArrowLeft className="h-4 w-4" />
</span>
Back to module
</Link>
<Card className="mb-10 border-grayScale-100/80 bg-white/90 shadow-md shadow-grayScale-200/40 backdrop-blur-sm">
<CardContent className="p-6 sm:p-8">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-brand-500 to-brand-600 text-white shadow-lg shadow-brand-500/25">
<BookOpen className="h-7 w-7" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-brand-500/90">
Lesson practices
</p>
<h1 className="mt-1.5 text-2xl font-semibold tracking-tight text-grayScale-900 sm:text-3xl">
{displayTitle}
</h1>
<p className="mt-2 max-w-xl text-[15px] leading-relaxed text-grayScale-500">
Review speaking practices linked to this lesson. Thumbnails
and copy come from your published practice content.
</p>
{!loading && !loadError ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
<Badge variant="default" className="px-3 py-1 text-xs font-semibold">
{practices.length}{" "}
{practices.length === 1 ? "practice" : "practices"}
</Badge>
{totalCount > practices.length ? (
<span className="text-[12px] text-grayScale-500">
Showing {practices.length} of {totalCount}
</span>
) : null}
</div>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 sm:flex-row lg:flex-col">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-6 font-semibold shadow-md shadow-brand-500/20 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Add practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 font-semibold text-grayScale-700 hover:bg-grayScale-50"
disabled={loading}
onClick={() => void load()}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
/>
Refresh
</Button>
</div>
</div>
</CardContent>
</Card>
{loading ? (
<Card className="border-grayScale-100 bg-white/95 py-20 shadow-sm">
<CardContent className="flex flex-col items-center justify-center gap-4 pt-6">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-50 ring-1 ring-brand-100">
<Loader2 className="h-8 w-8 animate-spin text-brand-500" />
</div>
<div className="text-center">
<p className="text-[16px] font-semibold text-grayScale-800">
Loading practices
</p>
<p className="mt-1 text-[14px] text-grayScale-500">
Fetching content for this lesson
</p>
</div>
</CardContent>
</Card>
) : loadError ? (
<Card className="border-red-100 bg-gradient-to-br from-red-50/90 to-white shadow-sm">
<CardContent className="flex flex-col items-center gap-5 py-14 text-center sm:py-16">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle className="h-8 w-8" />
</div>
<div>
<p className="text-lg font-semibold text-grayScale-900">
Something went wrong
</p>
<p className="mx-auto mt-2 max-w-md text-[14px] leading-relaxed text-grayScale-600">
{loadError}
</p>
</div>
<Button
type="button"
variant="outline"
className="rounded-xl border-grayScale-300 font-semibold"
onClick={() => void load()}
>
Try again
</Button>
</CardContent>
</Card>
) : practices.length === 0 ? (
<Card className="border-dashed border-grayScale-200 bg-white/90 shadow-sm">
<CardContent className="flex flex-col items-center px-6 py-16 text-center sm:py-20">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-violet-50 to-brand-50 ring-1 ring-brand-100/60">
<Sparkles className="h-9 w-9 text-brand-500" strokeWidth={1.5} />
</div>
<p className="text-xl font-semibold text-grayScale-900">
No practices yet
</p>
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-grayScale-500">
This lesson does not have any linked practices. Create one to
give learners a structured speaking activity after the video.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Button
type="button"
className="h-11 rounded-xl bg-brand-500 px-8 font-semibold shadow-md shadow-brand-500/15 hover:bg-brand-600"
onClick={() => void navigate(addPracticeHref)}
>
<Calendar className="mr-2 h-4 w-4" />
Create practice
</Button>
<Button
type="button"
variant="outline"
className="h-11 rounded-xl border-grayScale-200 px-8 font-semibold"
asChild
>
<Link to={backHref}>Return to module</Link>
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-5">
{practices.map((p, i) => (
<PracticeCard
key={p.id}
practice={p}
index={i}
total={practices.length}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,23 +1,27 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import { ArrowLeft, Video, Calendar, Trash2, X } from "lucide-react";
ArrowLeft,
Video,
Calendar,
Mic,
Layers,
Edit2,
Trash2,
X,
} from "lucide-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteTopLevelModuleLesson, deleteTopLevelModuleLesson,
getModuleLessons, getModuleLessons,
getPracticesByParentModule,
getTopLevelCourseModules, getTopLevelCourseModules,
publishParentLinkedPractice,
publishTopLevelModuleLesson,
updateParentLinkedPractice,
updateTopLevelModuleLesson, updateTopLevelModuleLesson,
} from "../../api/courses.api"; } from "../../api/courses.api";
import type { TopLevelModuleLessonItem } from "../../types/course.types"; import type {
ParentContextPractice,
PracticePublishStatus,
TopLevelModuleLessonItem,
} from "../../types/course.types";
import {
isPracticeDraft,
isPracticePublished,
unwrapPracticesList,
} from "../../lib/parentContextPractice";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
Dialog, Dialog,
@ -32,6 +36,7 @@ import { Textarea } from "../../components/ui/textarea";
import { resolveThumbnailForPreview } from "../../lib/videoPreview"; import { resolveThumbnailForPreview } from "../../lib/videoPreview";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { LessonMediaUploadField } from "./components/LessonMediaUploadField"; import { LessonMediaUploadField } from "./components/LessonMediaUploadField";
import { ModulePracticeCard } from "./components/ModulePracticeCard";
import { VideoCard } from "./components/VideoCard"; import { VideoCard } from "./components/VideoCard";
const LESSON_THUMB_GRADIENTS = [ const LESSON_THUMB_GRADIENTS = [
@ -41,37 +46,6 @@ const LESSON_THUMB_GRADIENTS = [
"from-[#FCE7F3] to-[#F9A8D4]", "from-[#FCE7F3] to-[#F9A8D4]",
] as const; ] as const;
const MOCK_PRACTICES = [
{
id: "p1",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p2",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p3",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
{
id: "p4",
title: "Describe a Photo",
level: "IELTS",
variations: 12,
status: "Draft",
},
];
type ModuleDetailState = { type ModuleDetailState = {
moduleName?: string; moduleName?: string;
moduleDescription?: string; moduleDescription?: string;
@ -87,13 +61,14 @@ export function ModuleDetailPage() {
moduleId: string; moduleId: string;
}>(); }>();
const [activeTab, setActiveTab] = useState<"video" | "practice">("video"); const [activeTab, setActiveTab] = useState<"video" | "practice">("video");
const [activeFilter, setActiveFilter] = useState("Draft"); const [activeFilter, setActiveFilter] = useState("All");
const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]); const [lessons, setLessons] = useState<TopLevelModuleLessonItem[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true); const [lessonsLoading, setLessonsLoading] = useState(true);
const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null); const [lessonsLoadError, setLessonsLoadError] = useState<string | null>(null);
const [editingLesson, setEditingLesson] = const [editingLesson, setEditingLesson] =
useState<TopLevelModuleLessonItem | null>(null); useState<TopLevelModuleLessonItem | null>(null);
const [editLessonTitle, setEditLessonTitle] = useState(""); const [editLessonTitle, setEditLessonTitle] = useState("");
const [editLessonSortOrder, setEditLessonSortOrder] = useState("");
const [editLessonVideoUrl, setEditLessonVideoUrl] = useState(""); const [editLessonVideoUrl, setEditLessonVideoUrl] = useState("");
const [editLessonThumbnail, setEditLessonThumbnail] = useState(""); const [editLessonThumbnail, setEditLessonThumbnail] = useState("");
const [editLessonDescription, setEditLessonDescription] = useState(""); const [editLessonDescription, setEditLessonDescription] = useState("");
@ -104,7 +79,17 @@ export function ModuleDetailPage() {
const [deletingLesson, setDeletingLesson] = const [deletingLesson, setDeletingLesson] =
useState<TopLevelModuleLessonItem | null>(null); useState<TopLevelModuleLessonItem | null>(null);
const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false); const [deletingLessonInFlight, setDeletingLessonInFlight] = useState(false);
const [practices] = useState(MOCK_PRACTICES); const [publishStatusLessonId, setPublishStatusLessonId] = useState<
number | null
>(null);
const [practices, setPractices] = useState<ParentContextPractice[]>([]);
const [practicesLoading, setPracticesLoading] = useState(false);
const [practicesLoadError, setPracticesLoadError] = useState<string | null>(
null,
);
const [publishStatusPracticeId, setPublishStatusPracticeId] = useState<
number | null
>(null);
const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null); const [loadedModuleName, setLoadedModuleName] = useState<string | null>(null);
const [loadedModuleDescription, setLoadedModuleDescription] = useState< const [loadedModuleDescription, setLoadedModuleDescription] = useState<
string | null string | null
@ -233,9 +218,96 @@ export function ModuleDetailPage() {
void loadModuleLessons({ showPageLoading: true }); void loadModuleLessons({ showPageLoading: true });
}, [loadModuleLessons]); }, [loadModuleLessons]);
const loadModulePractices = useCallback(async () => {
const mid = Number(moduleId);
if (!Number.isFinite(mid) || mid < 1) {
setPractices([]);
setPracticesLoadError(null);
setPracticesLoading(false);
return;
}
setPracticesLoading(true);
setPracticesLoadError(null);
try {
const res = await getPracticesByParentModule(mid, {
limit: 100,
offset: 0,
});
setPractices(unwrapPracticesList(res));
} catch {
setPractices([]);
setPracticesLoadError("Failed to load practices. Please try again.");
} finally {
setPracticesLoading(false);
}
}, [moduleId]);
useEffect(() => {
if (activeTab !== "practice") return;
void loadModulePractices();
}, [activeTab, loadModulePractices]);
const filteredPractices = useMemo(() => {
if (activeFilter === "Published") {
return practices.filter(isPracticePublished);
}
if (activeFilter === "Draft") {
return practices.filter(isPracticeDraft);
}
if (activeFilter === "Archived") {
return [];
}
return practices;
}, [practices, activeFilter]);
const handlePublishPractice = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
try {
await publishParentLinkedPractice(practiceId);
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "PUBLISHED" } : p,
),
);
toast.success("Practice published");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to publish practice";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const handleSavePracticeAsDraft = async (practiceId: number) => {
setPublishStatusPracticeId(practiceId);
try {
await updateParentLinkedPractice(practiceId, {
publish_status: "DRAFT",
});
setPractices((prev) =>
prev.map((p) =>
p.id === practiceId ? { ...p, publish_status: "DRAFT" } : p,
),
);
toast.success("Practice saved as draft");
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Failed to save practice as draft";
toast.error(msg);
} finally {
setPublishStatusPracticeId(null);
}
};
const openEditLesson = (lesson: TopLevelModuleLessonItem) => { const openEditLesson = (lesson: TopLevelModuleLessonItem) => {
setEditingLesson(lesson); setEditingLesson(lesson);
setEditLessonTitle(lesson.title ?? ""); setEditLessonTitle(lesson.title ?? "");
setEditLessonSortOrder(String(lesson.sort_order ?? 0));
setEditLessonVideoUrl(lesson.video_url ?? ""); setEditLessonVideoUrl(lesson.video_url ?? "");
setEditLessonThumbnail(lesson.thumbnail ?? ""); setEditLessonThumbnail(lesson.thumbnail ?? "");
setEditLessonDescription(lesson.description ?? ""); setEditLessonDescription(lesson.description ?? "");
@ -253,6 +325,16 @@ export function ModuleDetailPage() {
toast.error("Title is required"); toast.error("Title is required");
return; return;
} }
const sortOrderRaw = editLessonSortOrder.trim();
if (sortOrderRaw === "") {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingLessonEdit(true); setSavingLessonEdit(true);
try { try {
await updateTopLevelModuleLesson(editingLesson.id, { await updateTopLevelModuleLesson(editingLesson.id, {
@ -260,6 +342,7 @@ export function ModuleDetailPage() {
video_url: editLessonVideoUrl.trim(), video_url: editLessonVideoUrl.trim(),
thumbnail: editLessonThumbnail.trim(), thumbnail: editLessonThumbnail.trim(),
description: editLessonDescription.trim(), description: editLessonDescription.trim(),
sort_order,
}); });
toast.success("Lesson updated"); toast.success("Lesson updated");
setEditingLesson(null); setEditingLesson(null);
@ -275,6 +358,39 @@ export function ModuleDetailPage() {
} }
}; };
const handleToggleLessonPublishStatus = async (
lessonId: number,
nextStatus: PracticePublishStatus,
) => {
setPublishStatusLessonId(lessonId);
try {
await publishTopLevelModuleLesson(lessonId, {
publish_status: nextStatus,
});
setLessons((prev) =>
prev.map((l) =>
l.id === lessonId ? { ...l, publish_status: nextStatus } : l,
),
);
toast.success(
nextStatus === "PUBLISHED"
? "Lesson published"
: "Lesson saved as draft",
);
} catch (e: unknown) {
console.error(e);
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(nextStatus === "PUBLISHED"
? "Failed to publish lesson"
: "Failed to save lesson as draft");
toast.error(msg);
} finally {
setPublishStatusLessonId(null);
}
};
const handleConfirmDeleteLesson = async () => { const handleConfirmDeleteLesson = async () => {
if (!deletingLesson) return; if (!deletingLesson) return;
setDeletingLessonInFlight(true); setDeletingLessonInFlight(true);
@ -393,11 +509,34 @@ export function ModuleDetailPage() {
id={lesson.id} id={lesson.id}
title={lesson.title} title={lesson.title}
videoUrl={lesson.video_url} videoUrl={lesson.video_url}
publishStatus={lesson.publish_status}
hoverModuleActions hoverModuleActions
thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)} thumbnailUrl={resolveThumbnailForPreview(lesson.thumbnail)}
thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]} thumbnailGradient={LESSON_THUMB_GRADIENTS[i % LESSON_THUMB_GRADIENTS.length]}
durationSeconds={(() => {
const raw =
lesson.duration_seconds ?? lesson.duration ?? null;
if (raw == null) return null;
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
})()}
onEdit={() => openEditLesson(lesson)} onEdit={() => openEditLesson(lesson)}
onDelete={() => setDeletingLesson(lesson)} onDelete={() => setDeletingLesson(lesson)}
description={lesson.description}
onAddPractice={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lesson.id}&lessonTitle=${encodeURIComponent(lesson.title)}`,
)
}
onViewPractices={() =>
navigate(
`/new-content/learn-english/${level}/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}/practices?lessonTitle=${encodeURIComponent(lesson.title ?? "")}`,
)
}
onTogglePublishStatus={(nextStatus) =>
void handleToggleLessonPublishStatus(lesson.id, nextStatus)
}
publishStatusUpdating={publishStatusLessonId === lesson.id}
/> />
))} ))}
</div> </div>
@ -454,12 +593,66 @@ export function ModuleDetailPage() {
</div> </div>
</div> </div>
{/* Practice Cards Grid */} {practicesLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="flex flex-col items-center justify-center py-24 text-grayScale-500 text-[15px] font-medium">
{practices.map((practice) => ( Loading practices
<PracticeCard key={practice.id} {...practice} /> </div>
))} ) : practicesLoadError ? (
</div> <div className="rounded-2xl border border-amber-100 bg-amber-50/80 px-6 py-8 text-center text-sm text-amber-900 max-w-lg mx-auto">
{practicesLoadError}
</div>
) : filteredPractices.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{filteredPractices.map((practice) => (
<ModulePracticeCard
key={practice.id}
practice={practice}
statusUpdating={publishStatusPracticeId === practice.id}
onEdit={() =>
navigate(
`/content/practices?type=module&id=${moduleId}`,
)
}
onPublish={() => void handlePublishPractice(practice.id)}
onSaveAsDraft={() =>
void handleSavePracticeAsDraft(practice.id)
}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 px-4 rounded-[40px] border-2 border-dashed border-[#F1F5F9] bg-white max-w-4xl mx-auto shadow-sm">
<div className="h-20 w-20 rounded-full bg-[#FAF5FF] flex items-center justify-center mb-6">
<div className="h-14 w-14 rounded-full bg-[#F5EBFF] flex items-center justify-center">
<Calendar className="h-7 w-7 text-brand-500" />
</div>
</div>
<h2 className="text-2xl font-extrabold text-grayScale-900 mb-3">
{practices.length === 0
? "No practices in this module yet"
: "No practices match this filter"}
</h2>
<p className="text-grayScale-400 font-medium text-[15px] text-center max-w-sm mb-10 leading-relaxed">
{practices.length === 0
? "Add a practice to give learners speaking exercises for this module."
: "Try another status filter or add a new practice."}
</p>
{practices.length === 0 ? (
<Button
variant="outline"
className="h-12 px-8 rounded-xl border-brand-500 text-brand-500 font-bold hover:bg-brand-50 transition-all flex items-center gap-2"
onClick={() =>
navigate(
`/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}`,
)
}
>
<Calendar className="h-5 w-5" />
Add Practice
</Button>
) : null}
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -475,15 +668,7 @@ export function ModuleDetailPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>Edit lesson</DialogTitle> <DialogTitle>Edit lesson</DialogTitle>
<DialogDescription> <DialogDescription>
Update details. Video and thumbnail files use{" "} Update lesson details. Uploaded video and thumbnail files are stored automatically.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /files/upload
</code>
; the form is saved with{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /lessons/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
@ -501,6 +686,28 @@ export function ModuleDetailPage() {
disabled={savingLessonEdit} disabled={savingLessonEdit}
/> />
</div> </div>
<div className="space-y-2">
<label
className="text-sm font-medium text-grayScale-700"
htmlFor="edit-lesson-sort-order"
>
Sort order
</label>
<Input
id="edit-lesson-sort-order"
type="number"
inputMode="numeric"
min={0}
step={1}
value={editLessonSortOrder}
onChange={(e) => setEditLessonSortOrder(e.target.value)}
disabled={savingLessonEdit}
className="max-w-[200px]"
/>
<p className="text-xs text-grayScale-500">
Whole number, 0 or greater.
</p>
</div>
<LessonMediaUploadField <LessonMediaUploadField
kind="video" kind="video"
value={editLessonVideoUrl} value={editLessonVideoUrl}
@ -609,68 +816,3 @@ export function ModuleDetailPage() {
</div> </div>
); );
} }
function PracticeCard({
title,
level,
variations,
status,
}: {
title: string;
level: string;
variations: number;
status: string;
}) {
return (
<div className="bg-white rounded-[24px] border border-grayScale-50 shadow-sm overflow-hidden hover:shadow-xl hover:shadow-grayScale-400/5 transition-all group p-6 flex flex-col h-full min-h-[340px]">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-[18px] font-bold text-grayScale-900 line-clamp-1">
{title}
</h3>
</div>
<div className="flex items-center justify-between gap-3">
<span className="bg-[#22C55E] text-white text-[11px] font-bold px-2 py-1 rounded-[4px]">
{level}
</span>
<div className="flex items-center gap-1.5 text-grayScale-500">
<Mic className="h-4 w-4" />
<span className="text-[13px] font-bold">Speaking</span>
</div>
</div>
<div className="flex items-center gap-2.5 text-brand-400 w-fit py-2 rounded-xl">
<Layers className="h-4 w-4" />
<span className="text-[14px] font-bold">{variations} Variations</span>
</div>
<div className="flex border-t border-grayScale-200 items-center justify-between pt-2">
<div className="bg-grayScale-100 text-grayScale-400 text-[11px] font-bold px-3 py-1.5 rounded-[6px] tracking-wide uppercase">
{status}
</div>
<div className="flex items-center gap-3">
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-brand-500 hover:border-brand-100 transition-all">
<Edit2 className="h-5 w-5" />
</button>
<button className="h-8 w-8 rounded-lg flex items-center justify-center text-grayScale-400 hover:text-red-500 hover:border-red-100 transition-all">
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<Button className="bg-brand-500 text-white rounded-xl h-11 text-[13px] font-bold shadow-md shadow-brand-500/10 hover:bg-brand-600 transition-all px-0">
Publish Practice
</Button>
<Button
variant="outline"
className="border-brand-500 text-brand-500 rounded-xl h-11 text-[13px] font-bold bg-white hover:bg-brand-50 transition-all px-0"
>
Publish Video
</Button>
</div>
</div>
);
}

View File

@ -7,14 +7,31 @@ export function NewContentPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header section */} {/* Header section */}
<div> <div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700"> <div>
Content Management <h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
</h1> Content Management
<p className="mt-1 text-sm text-grayScale-500"> </h1>
Upload, organize, and manage learning content across programs and <p className="mt-1 text-sm text-grayScale-500">
courses Upload, organize, and manage learning content across programs and
</p> courses
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
<Link to="/new-content/question-types">
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all">
Manage Question Types
</Button>
</Link>
<Link to="/new-content/reorder">
<Button
variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 font-bold text-brand-500 hover:bg-brand-50 transition-all"
>
Reorder Content
</Button>
</Link>
</div>
</div> </div>
{/* Gradient Divider */} {/* Gradient Divider */}

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowLeft, Plus, FileText, Pencil, Trash2, X } from "lucide-react"; import { ArrowLeft, Plus, Pencil, Trash2, X } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent } from "../../components/ui/card"; import { Card, CardContent } from "../../components/ui/card";
@ -14,7 +14,6 @@ import {
DialogTrigger, DialogTrigger,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import uploadIcon from "../../assets/icons/upload.png"; import uploadIcon from "../../assets/icons/upload.png";
import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg"; import spinnerSrc from "../../assets/Circular-indeterminate progress indicator.svg";
import alertSrc from "../../assets/Alert.svg"; import alertSrc from "../../assets/Alert.svg";
@ -30,6 +29,7 @@ import type {
LearningProgramListItem, LearningProgramListItem,
ProgramCourseListItem, ProgramCourseListItem,
} from "../../types/course.types"; } from "../../types/course.types";
import { PublishPracticeButton } from "./components/PublishPracticeButton";
export function ProgramCoursesPage() { export function ProgramCoursesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -51,7 +51,7 @@ export function ProgramCoursesPage() {
null, null,
); );
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState(""); const [editSortOrder, setEditSortOrder] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false); const [uploadingEditThumbnail, setUploadingEditThumbnail] = useState(false);
@ -59,7 +59,7 @@ export function ProgramCoursesPage() {
const [createCourseOpen, setCreateCourseOpen] = useState(false); const [createCourseOpen, setCreateCourseOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState(""); const [createSortOrder, setCreateSortOrder] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [createSaving, setCreateSaving] = useState(false); const [createSaving, setCreateSaving] = useState(false);
const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false); const [createUploadingThumbnail, setCreateUploadingThumbnail] = useState(false);
@ -133,16 +133,16 @@ export function ProgramCoursesPage() {
const openEditCourse = (course: ProgramCourseListItem) => { const openEditCourse = (course: ProgramCourseListItem) => {
setEditingCourse(course); setEditingCourse(course);
setEditName(course.name ?? ""); setEditName(course.name ?? "");
setEditDescription(course.description?.trim() ?? "");
setEditThumbnail( setEditThumbnail(
course.thumbnail?.trim() || course.thumbnail_url?.trim() || "", course.thumbnail?.trim() || course.thumbnail_url?.trim() || "",
); );
setEditSortOrder(String(course.sort_order ?? 0));
}; };
const closeEditCourse = () => { const closeEditCourse = () => {
setEditingCourse(null); setEditingCourse(null);
setEditName(""); setEditName("");
setEditDescription(""); setEditSortOrder("");
setEditThumbnail(""); setEditThumbnail("");
setUploadingEditThumbnail(false); setUploadingEditThumbnail(false);
if (editThumbnailFileInputRef.current) { if (editThumbnailFileInputRef.current) {
@ -192,12 +192,23 @@ export function ProgramCoursesPage() {
toast.error("Course name is required"); toast.error("Course name is required");
return; return;
} }
const sortOrderRaw = editSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSavingEdit(true); setSavingEdit(true);
try { try {
await updateTopLevelCourse(editingCourse.id, { await updateTopLevelCourse(editingCourse.id, {
name, name,
description: editDescription.trim(), description: editingCourse.description?.trim() ?? "",
thumbnail: editThumbnail.trim(), thumbnail: editThumbnail.trim(),
sort_order,
}); });
toast.success("Course updated"); toast.success("Course updated");
closeEditCourse(); closeEditCourse();
@ -215,7 +226,7 @@ export function ProgramCoursesPage() {
const clearCreateCourseForm = () => { const clearCreateCourseForm = () => {
setCreateName(""); setCreateName("");
setCreateDescription(""); setCreateSortOrder("");
setCreateThumbnail(""); setCreateThumbnail("");
setCreateUploadingThumbnail(false); setCreateUploadingThumbnail(false);
if (createThumbnailFileInputRef.current) { if (createThumbnailFileInputRef.current) {
@ -271,12 +282,23 @@ export function ProgramCoursesPage() {
toast.error("Course name is required"); toast.error("Course name is required");
return; return;
} }
const sortOrderRaw = createSortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setCreateSaving(true); setCreateSaving(true);
try { try {
await createProgramCourse(programId, { await createProgramCourse(programId, {
name, name,
description: createDescription.trim(), description: "",
thumbnail: createThumbnail.trim(), thumbnail: createThumbnail.trim(),
sort_order,
}); });
toast.success("Course created"); toast.success("Course created");
clearCreateCourseForm(); clearCreateCourseForm();
@ -337,18 +359,6 @@ export function ProgramCoursesPage() {
<div className="flex gap-3"> <div className="flex gap-3">
{programIdValid ? ( {programIdValid ? (
<> <>
<Link
to={`/new-content/learn-english/${programIdParam}/courses/add-practice`}
>
<Button
variant="outline"
className="rounded-[6px] border-brand-500 text-brand-500 "
>
<FileText className="mr-2 h-4 w-4" />
Add Practice
</Button>
</Link>
<Dialog <Dialog
open={createCourseOpen} open={createCourseOpen}
onOpenChange={handleCreateCourseDialogOpenChange} onOpenChange={handleCreateCourseDialogOpenChange}
@ -369,15 +379,8 @@ export function ProgramCoursesPage() {
Add New Course Add New Course
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a course via{" "} Add a new course to this program. Use an image URL or upload a file for the
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600"> thumbnail.
POST /programs/:program_id/courses
</code>
. Thumbnail can be a URL or a file from{" "}
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px] text-grayScale-600">
POST /files/upload
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -422,17 +425,27 @@ export function ProgramCoursesPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700"> <label
Description htmlFor="create-course-sort-order"
className="text-[15px] font-medium text-grayScale-700"
>
Sort Order
</label> </label>
<Textarea <Input
value={createDescription} id="create-course-sort-order"
onChange={(e) => setCreateDescription(e.target.value)} type="number"
placeholder="Short summary of the course" min={0}
rows={3} step={1}
className="min-h-[88px] resize-y rounded-xl" inputMode="numeric"
value={createSortOrder}
onChange={(e) => setCreateSortOrder(e.target.value)}
placeholder="e.g. 5"
className="h-12 rounded-xl"
disabled={createSaving || createUploadingThumbnail} disabled={createSaving || createUploadingThumbnail}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when courses are listed.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -664,9 +677,11 @@ export function ProgramCoursesPage() {
> >
View Detail View Detail
</Button> </Button>
<Button className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold "> <PublishPracticeButton
Publish Practice parentKind="COURSE"
</Button> parentId={course.id}
className="h-10 flex-1 rounded-[6px] bg-brand-500 text-[13px] font-semibold hover:bg-brand-600 disabled:opacity-60"
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -682,18 +697,15 @@ export function ProgramCoursesPage() {
if (!open) closeEditCourse(); if (!open) closeEditCourse();
}} }}
> >
<DialogContent className="max-w-lg"> <DialogContent className="flex max-h-[min(90vh,calc(100dvh-2rem))] max-w-lg flex-col gap-0 overflow-hidden p-0">
<DialogHeader> <DialogHeader className="shrink-0 space-y-1.5 border-b border-grayScale-100 px-6 pb-4 pt-6 pr-12">
<DialogTitle>Edit course</DialogTitle> <DialogTitle>Edit course</DialogTitle>
<DialogDescription> <DialogDescription>
Update name, description, and thumbnail. Saved with{" "} Update name, sort order, and thumbnail.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
PUT /courses/:id
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-2"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4">
<div className="grid gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
Name Name
@ -707,17 +719,27 @@ export function ProgramCoursesPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label
Description htmlFor="edit-course-sort-order"
className="text-sm font-medium text-grayScale-700"
>
Sort Order
</label> </label>
<Textarea <Input
value={editDescription} id="edit-course-sort-order"
onChange={(e) => setEditDescription(e.target.value)} type="number"
rows={4} min={0}
className="min-h-[100px] resize-y rounded-xl" step={1}
placeholder="Short summary" inputMode="numeric"
value={editSortOrder}
onChange={(e) => setEditSortOrder(e.target.value)}
className="rounded-xl"
placeholder="e.g. 5"
disabled={savingEdit || uploadingEditThumbnail} disabled={savingEdit || uploadingEditThumbnail}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when courses are listed.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-grayScale-700"> <label className="text-sm font-medium text-grayScale-700">
@ -760,7 +782,8 @@ export function ProgramCoursesPage() {
/> />
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> </div>
<DialogFooter className="shrink-0 gap-2 border-t border-grayScale-100 bg-white px-6 py-4 sm:gap-0">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

View File

@ -13,7 +13,6 @@ import {
} from "lucide-react"; } 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 { Textarea } from "../../components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -39,7 +38,6 @@ export function ProgramDetailPage() {
const { programType } = useParams<{ programType: string }>(); const { programType } = useParams<{ programType: string }>();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false); const [createThumbnailFromUpload, setCreateThumbnailFromUpload] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -60,7 +58,6 @@ export function ProgramDetailPage() {
const [catalogLoading, setCatalogLoading] = useState(false); const [catalogLoading, setCatalogLoading] = useState(false);
const [editingCourseId, setEditingCourseId] = useState<number | null>(null); const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1"); const [editSortOrder, setEditSortOrder] = useState("1");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
@ -216,7 +213,7 @@ export function ProgramDetailPage() {
const response = await createExamPrepCatalogCourse({ const response = await createExamPrepCatalogCourse({
name, name,
description: createDescription.trim() || null, description: null,
thumbnail: thumbnailToSend, thumbnail: thumbnailToSend,
}); });
const row = response.data?.data; const row = response.data?.data;
@ -227,7 +224,7 @@ export function ProgramDetailPage() {
{ {
id: row.id, id: row.id,
name: row.name ?? name, name: row.name ?? name,
description: row.description?.trim() || createDescription.trim() || "—", description: row.description?.trim() || "—",
thumbnail: row.thumbnail?.trim() || null, thumbnail: row.thumbnail?.trim() || null,
sortOrder: Number(row.sort_order ?? 0), sortOrder: Number(row.sort_order ?? 0),
unitsCount: Number(row.units_count ?? 0), unitsCount: Number(row.units_count ?? 0),
@ -239,7 +236,6 @@ export function ProgramDetailPage() {
await loadCatalogCourses(); await loadCatalogCourses();
toast.success("Course created"); toast.success("Course created");
setCreateName(""); setCreateName("");
setCreateDescription("");
setCreateThumbnail(""); setCreateThumbnail("");
setCreateThumbnailFromUpload(false); setCreateThumbnailFromUpload(false);
setCreateOpen(false); setCreateOpen(false);
@ -259,7 +255,6 @@ export function ProgramDetailPage() {
if (!Number.isFinite(idNum)) return; if (!Number.isFinite(idNum)) return;
setEditingCourseId(idNum); setEditingCourseId(idNum);
setEditName(String(course.name ?? "")); setEditName(String(course.name ?? ""));
setEditDescription(String(course.description ?? ""));
setEditThumbnail(String(course.thumbnail ?? "")); setEditThumbnail(String(course.thumbnail ?? ""));
setEditSortOrder(String(course.sort_order ?? 1)); setEditSortOrder(String(course.sort_order ?? 1));
}; };
@ -268,7 +263,6 @@ export function ProgramDetailPage() {
if (savingEdit || uploadingEditThumbnail) return; if (savingEdit || uploadingEditThumbnail) return;
setEditingCourseId(null); setEditingCourseId(null);
setEditName(""); setEditName("");
setEditDescription("");
setEditThumbnail(""); setEditThumbnail("");
setEditSortOrder("1"); setEditSortOrder("1");
}; };
@ -317,9 +311,14 @@ export function ProgramDetailPage() {
setSavingEdit(true); setSavingEdit(true);
try { try {
const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail); const minioThumbnail = await resolveThumbnailToMinioUrl(editThumbnail);
const existing = createdCourses.find((c) => c.id === editingCourseId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const response = await updateExamPrepCatalogCourse(editingCourseId, { const response = await updateExamPrepCatalogCourse(editingCourseId, {
name, name,
description: editDescription.trim() || null, description: preservedDescription,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
sort_order: sortOrderNum, sort_order: sortOrderNum,
}); });
@ -330,7 +329,7 @@ export function ProgramDetailPage() {
? { ? {
...course, ...course,
name: row?.name ?? name, name: row?.name ?? name,
description: row?.description?.trim() || editDescription.trim() || "—", description: row?.description?.trim() || preservedDescription || "—",
thumbnail: row?.thumbnail?.trim() || null, thumbnail: row?.thumbnail?.trim() || null,
sortOrder: Number(row?.sort_order ?? sortOrderNum), sortOrder: Number(row?.sort_order ?? sortOrderNum),
unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0), unitsCount: Number(row?.units_count ?? course.unitsCount ?? 0),
@ -467,20 +466,6 @@ export function ProgramDetailPage() {
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800"> <label className="text-[15px] text-grayScale-800">
Thumbnail Thumbnail
@ -572,11 +557,11 @@ export function ProgramDetailPage() {
variant="outline" variant="outline"
className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2" className="h-10 px-6 rounded-[6px] border-brand-500 text-brand-500 font-bold flex items-center gap-2"
onClick={() => onClick={() =>
navigate(`/new-content/courses/${programType}/attach-practice`) navigate(`/new-content/courses/${programType}/add-practice`)
} }
> >
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
Attach Practice Add Practice
</Button> </Button>
</div> </div>
</div> </div>
@ -735,17 +720,6 @@ export function ProgramDetailPage() {
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label> <label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input <Input

View File

@ -1,26 +1,18 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { GraduationCap, Brain } from "lucide-react"; import { GraduationCap, Brain } from "lucide-react";
import { Button } from "../../components/ui/button";
export function ProgramTypeSelectionPage() { export function ProgramTypeSelectionPage() {
return ( return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header section */} {/* Header section */}
<div className="flex items-start justify-between"> <div className="space-y-1.5 pt-2">
<div className="space-y-1.5 pt-2"> <h1 className="text-[28px] font-bold tracking-tight text-grayScale-900">
<h1 className="text-[28px] font-bold tracking-tight text-grayScale-900"> Courses
Courses </h1>
</h1> <p className="max-w-2xl text-[15px] font-medium text-grayScale-500">
<p className="max-w-2xl text-[15px] font-medium text-grayScale-500"> Organize courses under skill-based learning or English proficiency
Organize courses under skill-based learning or English proficiency exams. Select a program type to manage curriculum and modules.
exams. Select a program type to manage curriculum and modules. </p>
</p>
</div>
<Link to="/new-content/question-types">
<Button className="h-10 px-6 rounded-[6px] bg-brand-500 font-bold text-white shadow-sm hover:bg-brand-600 transition-all flex items-center gap-2 mt-4">
Manage Question Types
</Button>
</Link>
</div> </div>
{/* Gradient Divider */} {/* Gradient Divider */}

View File

@ -1,127 +1,519 @@
import { useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"
import { Link } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, Plus, Search } from "lucide-react"; import {
import { Button } from "../../components/ui/button"; ArrowLeft,
import { Input } from "../../components/ui/input"; ChevronDown,
import { Select } from "../../components/ui/select"; ChevronLeft,
import { Card } from "../../components/ui/card"; ChevronRight,
import { cn } from "../../lib/utils"; Layers,
import { QuestionTypeCard } from "./components/QuestionTypeCard"; Plus,
RefreshCw,
Search,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog"
import { SpinnerIcon } from "../../components/ui/spinner-icon"
import { cn } from "../../lib/utils"
import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination"
import { QuestionTypeCard } from "./components/QuestionTypeCard"
import {
deleteQuestionTypeDefinition,
getQuestionTypeDefinitions,
} from "../../api/questionTypeDefinitions.api"
import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.types"
type StatusFilter = "All" | "ACTIVE" | "INACTIVE"
type ScopeFilter = "all" | "system" | "custom"
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "All", label: "All statuses" },
{ value: "ACTIVE", label: "Active" },
{ value: "INACTIVE", label: "Inactive" },
]
const SCOPE_OPTIONS: { value: ScopeFilter; label: string }[] = [
{ value: "all", label: "All types" },
{ value: "system", label: "System only" },
{ value: "custom", label: "Custom only" },
]
const SYSTEM_SCOPE_FETCH_LIMIT = 100
export function QuestionTypeLibraryPage() { export function QuestionTypeLibraryPage() {
const [activeTab, setActiveTab] = useState("All"); const navigate = useNavigate()
const [searchParams] = useSearchParams()
const createdId = searchParams.get("created")
const updatedId = searchParams.get("updated")
const questionTypes = [ const [loading, setLoading] = useState(true)
{ const [definitions, setDefinitions] = useState<QuestionTypeDefinition[]>([])
title: "Describe a Photo", const [totalCount, setTotalCount] = useState<number | undefined>(undefined)
exam: "DUOLINGO" as const, const [query, setQuery] = useState("")
skill: "Speaking" as const, const [statusFilter, setStatusFilter] = useState<StatusFilter>("All")
variations: 12, const [scopeFilter, setScopeFilter] = useState<ScopeFilter>("all")
status: "Published" as const, const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE)
}, const [offset, setOffset] = useState(0)
{ const [definitionPendingDelete, setDefinitionPendingDelete] = useState<QuestionTypeDefinition | null>(null)
title: "Write About the Topic", const [deleteSubmitting, setDeleteSubmitting] = useState(false)
exam: "DUOLINGO" as const,
skill: "Writing" as const, const hasActiveFilters =
variations: 12, query.trim().length > 0 || statusFilter !== "All" || scopeFilter !== "all"
status: "Published" as const,
}, const load = useCallback(async () => {
{ setLoading(true)
title: "Fill in the Blanks", try {
exam: "IELTS" as const, const isSystemScope = scopeFilter === "system"
skill: "Writing" as const, const { definitions: rows, total_count } = await getQuestionTypeDefinitions({
variations: 12, include_system: scopeFilter !== "custom",
status: "Published" as const, ...(statusFilter !== "All" ? { status: statusFilter } : {}),
}, limit: isSystemScope ? SYSTEM_SCOPE_FETCH_LIMIT : pageSize,
{ offset: isSystemScope ? 0 : offset,
title: "Describe a Photo", })
exam: "DUOLINGO" as const, const visibleRows = isSystemScope ? rows.filter((d) => d.is_system) : rows
skill: "Speaking" as const, setDefinitions(visibleRows)
variations: 12, if (isSystemScope) {
status: "Published" as const, setTotalCount(visibleRows.length)
}, } else if (total_count != null) {
]; setTotalCount(total_count)
} else if (rows.length < pageSize) {
setTotalCount(offset + rows.length)
} else {
setTotalCount(undefined)
}
} catch (e) {
console.error(e)
toast.error("Failed to load question type definitions")
setDefinitions([])
setTotalCount(0)
} finally {
setLoading(false)
}
}, [offset, pageSize, scopeFilter, statusFilter])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (createdId) {
toast.success("Definition created", { description: `Id ${createdId}` })
}
}, [createdId])
useEffect(() => {
if (updatedId) {
toast.success("Definition updated", { description: `Id ${updatedId}` })
}
}, [updatedId])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return definitions
return definitions.filter((d) => {
const name = (d.display_name || "").toLowerCase()
const key = (d.key || "").toLowerCase()
return name.includes(q) || key.includes(q) || String(d.id).includes(q)
})
}, [definitions, query])
const isSystemScope = scopeFilter === "system"
const canPrev = !isSystemScope && offset > 0
const canNext =
!isSystemScope &&
(totalCount != null ? offset + pageSize < totalCount : definitions.length === pageSize)
const pageStart = totalCount === 0 ? 0 : isSystemScope ? 1 : offset + 1
const pageEnd =
isSystemScope
? filtered.length
: totalCount != null
? Math.min(offset + definitions.length, totalCount)
: offset + definitions.length
const resetPagination = () => setOffset(0)
const clearFilters = () => {
setQuery("")
setStatusFilter("All")
setScopeFilter("all")
setOffset(0)
}
const openDeleteConfirm = (row: QuestionTypeDefinition) => {
if (row.is_system) {
toast.error("System definitions cannot be deleted.")
return
}
setDefinitionPendingDelete(row)
}
const handleDeleteDialogOpenChange = (open: boolean) => {
if (!open && !deleteSubmitting) setDefinitionPendingDelete(null)
}
const handleConfirmDeleteDefinition = async () => {
const row = definitionPendingDelete
if (!row) return
setDeleteSubmitting(true)
try {
await deleteQuestionTypeDefinition(row.id)
toast.success("Definition deleted")
setDefinitionPendingDelete(null)
void load()
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } }
toast.error(String(err.response?.data?.message || "Delete failed"))
} finally {
setDeleteSubmitting(false)
}
}
return ( return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20"> <div className="space-y-8 animate-in fade-in duration-500 pb-20">
{/* Navigation & Header */}
<div className="space-y-6"> <div className="space-y-6">
<Link <Link
to="/new-content/courses" to="/new-content"
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit" className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
> >
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" /> <ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Courses Back to Content Management
</Link> </Link>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight"> <h1 className="text-[32px] font-medium text-grayScale-900 tracking-tight">Question type definitions</h1>
Question Type Library <p className="text-grayScale-500 text-[16px] font-medium max-w-2xl">
</h1> Reusable templates that define how practice and assessment questions are structured and answered.
<p className="text-grayScale-500 text-[16px] font-medium">
Create and manage reusable question structures for practices and
assessments.
</p> </p>
</div> </div>
<Link to="/new-content/question-types/create"> <Link to="/new-content/question-types/create">
<Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3"> <Button className="h-12 px-8 rounded-[10px] bg-[#9E2891] font-bold text-white shadow-lg shadow-brand-500/10 hover:bg-[#8A237E] transition-all flex items-center gap-3">
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
Create Question Type Create definition
</Button> </Button>
</Link> </Link>
</div> </div>
</div> </div>
{/* Control Bar */} <Card className="overflow-hidden rounded-2xl border border-grayScale-200 bg-white shadow-none">
<Card className="p-6 border-grayScale-200 rounded-2xl bg-white space-y-6"> <CardHeader className="border-b border-grayScale-100 px-6 py-5">
<div className="flex items-center gap-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative flex-1"> <div className="flex items-center gap-3">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-grayScale-600" /> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
<Input <Layers className="h-5 w-5" aria-hidden />
className="h-10 pl-12 rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 bg-[#F8FAFC] transition-all text-sm" </div>
placeholder="Search by practice name, ID, or keywords..." <div>
/> <CardTitle className="text-base font-bold text-grayScale-900">Definition library</CardTitle>
</div> <p className="text-xs text-grayScale-500 mt-0.5">
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm"> Browse and filter templates from the question type catalog
<option>All Exams</option> </p>
<option>IELTS</option> </div>
<option>Duolingo</option> </div>
</Select> <Button
<Select className="h-10 w-[180px] rounded-[6px] border-grayScale-200 placeholder:text-grayScale-600 text-grayScale-700 bg-[#F8FAFC] transition-all text-sm"> type="button"
<option>All Skills</option> variant="outline"
<option>Speaking</option> size="sm"
<option>Writing</option> className="rounded-[8px] border-grayScale-200"
</Select> disabled={loading}
</div> onClick={() => void load()}
<div className="flex items-center gap-3">
<span className="text-[12px] font-medium text-grayScale-400 uppercase tracking-widest mr-2">
STATUS:
</span>
{["All", "Published", "Drafts", "Archived"].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"h-10 px-4 rounded-full text-[13px] font-medium transition-all",
activeTab === tab
? "bg-[#9E2891] text-white shadow-md shadow-brand-500/20"
: "bg-grayScale-100 text-grayScale-400 hover:bg-grayScale-100",
)}
> >
{tab} <RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
</button> Refresh
))} </Button>
</div> </div>
</CardHeader>
<CardContent className="p-0">
<div className="border-b border-grayScale-100 bg-grayScale-50/60 px-6 py-5 space-y-4">
<div className="relative">
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
className="h-11 pl-11 pr-10 rounded-[10px] border-grayScale-200 bg-white placeholder:text-grayScale-400 text-sm shadow-sm"
placeholder="Search by display name, key, or id on this page…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-grayScale-400 hover:bg-grayScale-100 hover:text-grayScale-600"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Status
</span>
{STATUS_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={statusFilter === value}
disabled={loading}
onClick={() => {
setStatusFilter(value)
resetPagination()
}}
/>
))}
</div>
<span className="hidden h-5 w-px bg-grayScale-200 sm:block" />
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Scope
</span>
{SCOPE_OPTIONS.map(({ value, label }) => (
<FilterChip
key={value}
label={label}
active={scopeFilter === value}
disabled={loading}
onClick={() => {
setScopeFilter(value)
resetPagination()
}}
/>
))}
</div>
</div>
{hasActiveFilters ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0 self-start rounded-[8px] px-3 text-xs font-semibold text-grayScale-500 hover:text-brand-600 lg:self-center"
disabled={loading}
onClick={clearFilters}
>
Clear filters
</Button>
) : null}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20">
<SpinnerIcon className="h-8 w-8 text-brand-500" />
<p className="text-sm text-grayScale-500">Loading definitions</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-20 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-grayScale-100 text-grayScale-400">
<Layers className="h-7 w-7" aria-hidden />
</div>
<p className="text-sm font-semibold text-grayScale-700">
{hasActiveFilters ? "No definitions match your filters" : "No definitions yet"}
</p>
<p className="max-w-sm text-xs text-grayScale-500">
{hasActiveFilters
? "Try different filters or clear them to see more results."
: "Create a definition to start building custom question templates."}
</p>
{hasActiveFilters ? (
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px]"
onClick={clearFilters}
>
Clear filters
</Button>
) : (
<Link to="/new-content/question-types/create">
<Button size="sm" className="rounded-[8px] bg-brand-600 hover:bg-brand-500">
<Plus className="mr-2 h-4 w-4" />
Create definition
</Button>
</Link>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-5 px-6 py-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((d) => (
<QuestionTypeCard
key={d.id}
id={d.id}
definitionKey={d.key}
display_name={d.display_name}
status={d.status}
is_system={d.is_system}
stimulusKindsCount={d.stimulus_component_kinds?.length ?? 0}
responseKindsCount={d.response_component_kinds?.length ?? 0}
deleteDisabled={!!d.is_system}
onEdit={() => navigate(`/new-content/question-types/${d.id}/edit`)}
onDelete={() => openDeleteConfirm(d)}
/>
))}
</div>
)}
{!loading && (definitions.length > 0 || offset > 0) ? (
<div className="flex flex-wrap items-center justify-between gap-4 border-t border-grayScale-100 bg-white px-6 py-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-500">
<span>
{totalCount != null
? `Showing ${pageStart}${pageEnd} of ${totalCount}`
: `Showing ${pageStart}${pageEnd}`}
</span>
{query.trim() && filtered.length !== definitions.length ? (
<span className="rounded-full bg-brand-50 px-2.5 py-0.5 text-[11px] font-semibold text-brand-600">
{filtered.length} match{filtered.length === 1 ? "" : "es"} on this page
</span>
) : null}
{!isSystemScope ? (
<>
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
<span className="flex items-center gap-2">
Per page
<div className="relative">
<select
value={pageSize}
disabled={loading}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-[8px] border border-grayScale-200 bg-white pl-2.5 pr-8 text-sm font-medium text-grayScale-600 focus:outline-none focus:ring-2 focus:ring-brand-200"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
</div>
</span>
</>
) : null}
</div>
{!isSystemScope ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canPrev || loading}
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-[8px] border-grayScale-200"
disabled={!canNext || loading}
onClick={() => setOffset((o) => o + pageSize)}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
) : null}
</div>
) : null}
</CardContent>
</Card> </Card>
{/* Grid of Cards */} <Dialog open={definitionPendingDelete !== null} onOpenChange={handleDeleteDialogOpenChange}>
<div className="grid grid-cols-4 gap-6"> <DialogContent className="max-w-md rounded-2xl border-grayScale-200 sm:max-w-md">
{questionTypes.map((qt, index) => ( <DialogHeader>
<QuestionTypeCard key={index} {...qt} /> <DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
))} <Trash2 className="h-5 w-5 text-red-600 shrink-0" aria-hidden />
</div> Delete question type definition?
</DialogTitle>
<DialogDescription className="text-left text-grayScale-600">
This removes the template from the library. Existing questions that reference it may be affected. This
action cannot be undone.
</DialogDescription>
</DialogHeader>
{definitionPendingDelete ? (
<div className="rounded-xl border border-grayScale-200 bg-grayScale-50 px-4 py-3 space-y-1">
<p className="text-sm font-semibold text-grayScale-900">{definitionPendingDelete.display_name}</p>
<p className="text-xs font-mono text-grayScale-500 break-all">
#{definitionPendingDelete.id} · {definitionPendingDelete.key}
</p>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
className="border-grayScale-200"
disabled={deleteSubmitting}
onClick={() => setDefinitionPendingDelete(null)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteSubmitting}
className="gap-2"
onClick={() => void handleConfirmDeleteDefinition()}
>
{deleteSubmitting ? <SpinnerIcon className="h-4 w-4" /> : <Trash2 className="h-4 w-4" aria-hidden />}
{deleteSubmitting ? "Deleting…" : "Delete definition"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); )
}
function FilterChip({
label,
active,
disabled,
onClick,
}: {
label: string
active: boolean
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-sm shadow-brand-500/20"
: "border-grayScale-200 bg-white text-grayScale-600 hover:border-brand-200 hover:text-brand-600",
disabled && "pointer-events-none opacity-50",
)}
>
{label}
</button>
)
} }

View File

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

View File

@ -0,0 +1,31 @@
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { ContentHierarchyList } from "./components/ContentHierarchyList";
export function ReorderContentPage() {
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
<div className="space-y-6">
<Link
to="/new-content"
className="flex items-center gap-2 text-[15px] font-bold text-grayScale-600 transition-colors hover:text-brand-500 group w-fit"
>
<ArrowLeft className="h-5 w-5 transition-transform group-hover:-translate-x-1" />
Back to Content Management
</Link>
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight text-grayScale-700">
Reorder Content
</h1>
<p className="max-w-2xl text-sm text-grayScale-500">
Drag and drop programs, courses, modules, and lessons to change
their display order.
</p>
</div>
</div>
<ContentHierarchyList />
</div>
);
}

View File

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

View File

@ -13,7 +13,6 @@ import {
} from "lucide-react"; } 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 { Textarea } from "../../components/ui/textarea";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -55,7 +54,6 @@ export function UnitManagementPage() {
const parsedUnitId = Number(unitId); const parsedUnitId = Number(unitId);
const [addModuleOpen, setAddModuleOpen] = useState(false); const [addModuleOpen, setAddModuleOpen] = useState(false);
const [createName, setCreateName] = useState(""); const [createName, setCreateName] = useState("");
const [createDescription, setCreateDescription] = useState("");
const [createThumbnail, setCreateThumbnail] = useState(""); const [createThumbnail, setCreateThumbnail] = useState("");
const [createIcon, setCreateIcon] = useState(""); const [createIcon, setCreateIcon] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -79,7 +77,6 @@ export function UnitManagementPage() {
>([]); >([]);
const [editingModuleId, setEditingModuleId] = useState<number | null>(null); const [editingModuleId, setEditingModuleId] = useState<number | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editThumbnail, setEditThumbnail] = useState(""); const [editThumbnail, setEditThumbnail] = useState("");
const [editIcon, setEditIcon] = useState(""); const [editIcon, setEditIcon] = useState("");
const [editSortOrder, setEditSortOrder] = useState("1"); const [editSortOrder, setEditSortOrder] = useState("1");
@ -159,7 +156,6 @@ export function UnitManagementPage() {
const clearCreateModuleForm = () => { const clearCreateModuleForm = () => {
setCreateName(""); setCreateName("");
setCreateDescription("");
setCreateThumbnail(""); setCreateThumbnail("");
setCreateIcon(""); setCreateIcon("");
if (createThumbnailFileInputRef.current) { if (createThumbnailFileInputRef.current) {
@ -264,7 +260,7 @@ export function UnitManagementPage() {
const minioIcon = await resolveToMinioUrl(createIcon); const minioIcon = await resolveToMinioUrl(createIcon);
await createExamPrepUnitModule(parsedUnitId, { await createExamPrepUnitModule(parsedUnitId, {
name, name,
description: createDescription.trim() || null, description: null,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
icon: minioIcon || null, icon: minioIcon || null,
}); });
@ -286,7 +282,6 @@ export function UnitManagementPage() {
const openEditModule = (module: (typeof modules)[number]) => { const openEditModule = (module: (typeof modules)[number]) => {
setEditingModuleId(module.id); setEditingModuleId(module.id);
setEditName(module.name ?? ""); setEditName(module.name ?? "");
setEditDescription(module.description ?? "");
setEditThumbnail(module.thumbnail ?? ""); setEditThumbnail(module.thumbnail ?? "");
setEditIcon(module.icon ?? ""); setEditIcon(module.icon ?? "");
setEditSortOrder(String(module.sortOrder ?? 1)); setEditSortOrder(String(module.sortOrder ?? 1));
@ -296,7 +291,6 @@ export function UnitManagementPage() {
if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return; if (savingEdit || uploadingEditThumbnail || uploadingEditIcon) return;
setEditingModuleId(null); setEditingModuleId(null);
setEditName(""); setEditName("");
setEditDescription("");
setEditThumbnail(""); setEditThumbnail("");
setEditIcon(""); setEditIcon("");
setEditSortOrder("1"); setEditSortOrder("1");
@ -391,11 +385,16 @@ export function UnitManagementPage() {
setSavingEdit(true); setSavingEdit(true);
try { try {
const existing = modules.find((m) => m.id === editingModuleId);
const preservedDescription =
existing?.description && existing.description !== "—"
? existing.description
: null;
const minioThumbnail = await resolveToMinioUrl(editThumbnail); const minioThumbnail = await resolveToMinioUrl(editThumbnail);
const minioIcon = await resolveToMinioUrl(editIcon); const minioIcon = await resolveToMinioUrl(editIcon);
await updateExamPrepUnitModule(editingModuleId, { await updateExamPrepUnitModule(editingModuleId, {
name, name,
description: editDescription.trim() || null, description: preservedDescription,
thumbnail: minioThumbnail || null, thumbnail: minioThumbnail || null,
icon: minioIcon || null, icon: minioIcon || null,
sort_order: sortOrderNum, sort_order: sortOrderNum,
@ -489,20 +488,6 @@ export function UnitManagementPage() {
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">
Description
</label>
<Textarea
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
placeholder="Optional module description"
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={creating || uploadingThumbnail || uploadingIcon}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Thumbnail</label> <label className="text-[15px] text-grayScale-800">Thumbnail</label>
<input <input
@ -812,16 +797,6 @@ export function UnitManagementPage() {
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon} disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/> />
</div> </div>
<div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Description</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={4}
className="min-h-[96px] rounded-[8px] border-grayScale-400"
disabled={savingEdit || uploadingEditThumbnail || uploadingEditIcon}
/>
</div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[15px] text-grayScale-800">Sort Order</label> <label className="text-[15px] text-grayScale-800">Sort Order</label>
<Input <Input

View File

@ -9,7 +9,6 @@ import {
DialogClose, DialogClose,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "sonner"; import { toast } from "sonner";
import { createTopLevelCourseModule } from "../../../api/courses.api"; import { createTopLevelCourseModule } from "../../../api/courses.api";
import { ModuleIconUploadField } from "./ModuleIconUploadField"; import { ModuleIconUploadField } from "./ModuleIconUploadField";
@ -28,7 +27,7 @@ export function AddModuleModal({
onCreated, onCreated,
}: AddModuleModalProps) { }: AddModuleModalProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [sortOrder, setSortOrder] = useState("");
const [icon, setIcon] = useState(""); const [icon, setIcon] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [iconUploadBusy, setIconUploadBusy] = useState(false); const [iconUploadBusy, setIconUploadBusy] = useState(false);
@ -36,7 +35,7 @@ export function AddModuleModal({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setName(""); setName("");
setDescription(""); setSortOrder("");
setIcon(""); setIcon("");
setSubmitting(false); setSubmitting(false);
setIconUploadBusy(false); setIconUploadBusy(false);
@ -45,7 +44,7 @@ export function AddModuleModal({
const resetAndClose = () => { const resetAndClose = () => {
setName(""); setName("");
setDescription(""); setSortOrder("");
setIcon(""); setIcon("");
setIconUploadBusy(false); setIconUploadBusy(false);
onClose(); onClose();
@ -69,12 +68,23 @@ export function AddModuleModal({
toast.error("Invalid course"); toast.error("Invalid course");
return; return;
} }
const sortOrderRaw = sortOrder.trim();
if (!sortOrderRaw) {
toast.error("Sort order is required");
return;
}
const sort_order = Number(sortOrderRaw);
if (!Number.isInteger(sort_order) || sort_order < 0) {
toast.error("Sort order must be a whole number of 0 or greater");
return;
}
setSubmitting(true); setSubmitting(true);
try { try {
await createTopLevelCourseModule(courseId, { await createTopLevelCourseModule(courseId, {
name: trimmedName, name: trimmedName,
description: description.trim(), description: "",
icon: icon.trim(), icon: icon.trim(),
sort_order,
}); });
toast.success("Module created"); toast.success("Module created");
if (onCreated) { if (onCreated) {
@ -101,11 +111,7 @@ export function AddModuleModal({
Add New Module Add New Module
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm text-grayScale-400"> <DialogDescription className="text-sm text-grayScale-400">
Create a module with{" "} Add a new module to this course.
<code className="rounded bg-grayScale-100 px-1 py-0.5 text-[11px]">
POST /courses/:courseId/modules
</code>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -144,17 +150,27 @@ export function AddModuleModal({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[15px] font-medium text-grayScale-700"> <label
Description htmlFor="create-module-sort-order"
className="text-[15px] font-medium text-grayScale-700"
>
Sort Order
</label> </label>
<Textarea <Input
value={description} id="create-module-sort-order"
onChange={(e) => setDescription(e.target.value)} type="number"
placeholder="Learn to introduce yourself and talk about your life." min={0}
className="min-h-[88px] resize-y rounded-xl" step={1}
disabled={submitting} inputMode="numeric"
rows={3} value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
placeholder="e.g. 5"
className="h-12 rounded-xl"
disabled={submitting || iconUploadBusy}
/> />
<p className="text-xs text-grayScale-500">
Lower numbers appear first when modules are listed.
</p>
</div> </div>
<ModuleIconUploadField <ModuleIconUploadField

View File

@ -0,0 +1,104 @@
import { useMemo } from "react";
import {
PracticeSequentialReview,
type PracticeReviewQuestion,
} from "./practice-steps/PracticeSequentialReview";
import type { PersonaCardModel } from "../../../lib/personaDisplay";
type IntroVideoPreview =
| { kind: "vimeo"; url: string }
| { kind: "video"; url: string }
| null;
function plainTextFromHtml(raw: string): string {
if (!raw.trim()) return "";
if (!/<\/?[a-z][\s\S]*>/i.test(raw)) return raw.trim();
try {
const doc = new DOMParser().parseFromString(raw, "text/html");
return doc.body.textContent?.replace(/\s+/g, " ").trim() ?? "";
} catch {
return raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
}
export type AddNewPracticeReviewStepProps = {
practiceTitle: string;
practiceDescription: string;
selectedProgram: string;
selectedCourse: string;
moduleLabel: string;
selectedPersona: string | null;
personas: PersonaCardModel[];
introVideoPreview: IntroVideoPreview;
questions: PracticeReviewQuestion[];
saving: boolean;
saveError: string | null;
onEditContext: () => void;
onEditQuestions: () => void;
onBack: () => void;
onSaveDraft: () => void;
onPublish: () => void;
};
export function AddNewPracticeReviewStep({
practiceTitle,
practiceDescription,
selectedProgram,
selectedCourse,
moduleLabel,
selectedPersona,
personas,
introVideoPreview,
questions,
saving,
saveError,
onEditContext,
onEditQuestions,
onBack,
onSaveDraft,
onPublish,
}: AddNewPracticeReviewStepProps) {
const persona = personas.find((p) => p.id === selectedPersona);
const guidanceText = useMemo(() => {
const fromDescription = plainTextFromHtml(practiceDescription);
if (fromDescription) return fromDescription;
const tips = questions
.map((q) => q.tips?.trim() ?? "")
.filter(Boolean)
.join(" ");
return tips || "—";
}, [practiceDescription, questions]);
const thumbnailKind =
introVideoPreview?.kind === "video"
? "video"
: introVideoPreview?.kind === "vimeo"
? "vimeo"
: "gradient";
return (
<PracticeSequentialReview
practiceTitle={practiceTitle}
thumbnailUrl={
introVideoPreview?.kind === "video" ? introVideoPreview.url : null
}
thumbnailKind={thumbnailKind}
persona={persona ?? null}
metadata={[
{ label: "Program", value: selectedProgram },
{ label: "Course", value: selectedCourse },
{ label: "Module", value: moduleLabel },
]}
guidanceText={guidanceText}
questions={questions}
saving={saving}
saveError={saveError}
onEditContext={onEditContext}
onEditQuestions={onEditQuestions}
onBack={onBack}
onSaveDraft={onSaveDraft}
onPublish={onPublish}
/>
);
}

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