rogasper commited on
Commit
9205030
Β·
1 Parent(s): af24968

feat: update accessibility guidelines and enhance component structure. Introduce new frontend accessibility conventions in AGENTS.md, including heading hierarchy and ARIA roles for dialogs and buttons. Refactor TourGuide and related components for lazy loading and improved user experience. Update ESLint configuration to enforce stricter TypeScript rules and add new dependencies for linting. Enhance various components with accessibility features and ensure consistent styling across the application.

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. AGENTS.md +48 -2
  2. DESIGN.md +86 -0
  3. apps/web/eslint.config.mjs +17 -0
  4. apps/web/package.json +4 -0
  5. apps/web/src/components/GettingStartedCard.tsx +1 -0
  6. apps/web/src/components/TourGuide.tsx +11 -80
  7. apps/web/src/components/TourGuideImpl.tsx +106 -0
  8. apps/web/src/components/analytics/BreakdownCharts.tsx +3 -3
  9. apps/web/src/components/analytics/ChartsBundle.tsx +45 -0
  10. apps/web/src/components/analytics/TimeAnalyticsPanel.tsx +3 -3
  11. apps/web/src/components/attempt/OptionDisplay.tsx +16 -6
  12. apps/web/src/components/attempt/QuestionReviewCard.tsx +6 -5
  13. apps/web/src/components/bank/AutoBundleModal.tsx +14 -19
  14. apps/web/src/components/bank/BundleSidebar.tsx +7 -6
  15. apps/web/src/components/bank/CreatePackageModal.tsx +14 -20
  16. apps/web/src/components/bank/FilterBar.tsx +58 -4
  17. apps/web/src/components/bank/QuestionCard.tsx +4 -2
  18. apps/web/src/components/bank/QuestionDetailModal.tsx +31 -33
  19. apps/web/src/components/bank/SectionBrowser.tsx +6 -5
  20. apps/web/src/components/bank/SoalBrowser.tsx +8 -7
  21. apps/web/src/components/generate/ResultSection.tsx +1 -1
  22. apps/web/src/components/package/EditPackageModal.tsx +14 -20
  23. apps/web/src/components/routes/BankPage.tsx +36 -33
  24. apps/web/src/components/routes/GeneratePage.tsx +48 -14
  25. apps/web/src/components/routes/PackagesPage.tsx +22 -22
  26. apps/web/src/components/routes/SettingsPage.tsx +2 -2
  27. apps/web/src/components/settings/AccountSettings.tsx +5 -4
  28. apps/web/src/components/test/AccentKeyboard.tsx +4 -3
  29. apps/web/src/components/test/AttemptTestView.tsx +14 -13
  30. apps/web/src/components/test/QuestionInput.tsx +5 -2
  31. apps/web/src/components/test/attempt/AbandonDialog.tsx +24 -29
  32. apps/web/src/components/test/attempt/FinishDialog.tsx +29 -29
  33. apps/web/src/components/test/attempt/FloatingNav.tsx +24 -1
  34. apps/web/src/hooks/use-generation-jobs.ts +93 -327
  35. apps/web/src/hooks/use-job-shared.ts +66 -0
  36. apps/web/src/hooks/use-job-state.ts +243 -0
  37. apps/web/src/hooks/use-package-builder.ts +6 -5
  38. apps/web/src/hooks/use-polling-transport.ts +80 -0
  39. apps/web/src/hooks/use-question-selection.ts +1 -1
  40. apps/web/src/hooks/use-test-session.ts +3 -2
  41. apps/web/src/lib/types.ts +86 -0
  42. apps/web/src/routes/__root.tsx +13 -0
  43. apps/web/src/routes/admin.credits.tsx +7 -0
  44. apps/web/src/routes/admin.featured.tsx +145 -108
  45. apps/web/src/routes/admin.jobs.tsx +1 -1
  46. apps/web/src/routes/admin.moderation.tsx +1 -1
  47. apps/web/src/routes/admin.tsx +1 -1
  48. apps/web/src/routes/admin.users.tsx +3 -2
  49. apps/web/src/routes/analytics.tsx +23 -23
  50. apps/web/src/routes/attempt.$id.tsx +30 -12
AGENTS.md CHANGED
@@ -93,6 +93,9 @@ bun run build # Build all packages
93
  - ❌ **Do not assume Prisma**. Database layer is **Drizzle ORM**.
94
  - ❌ **Do not hardcode API keys** in source code. AI keys are user-managed via the Settings UI.
95
  - ❌ **Do not add global CSS** in `apps/web` without checking `packages/ui/src/styles/globals.css` for existing design tokens and CSS variables.
 
 
 
96
 
97
  ---
98
 
@@ -109,6 +112,25 @@ bun run build # Build all packages
109
  npx shadcn@latest add accordion dialog -c packages/ui
110
  ```
111
  - App-specific blocks (non-shared) should be added directly in `apps/web/src/components/`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  ---
114
 
@@ -187,7 +209,31 @@ bun run build # Build all packages
187
  - Routers live in `packages/api/src/routers/`.
188
  - Register new routers in `packages/api/src/routers/index.ts`.
189
 
190
- ## 11. Git & Workflow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
  - Do **NOT** run `git commit`, `git push`, `git rebase`, or force-push unless the user explicitly asks for it.
193
  - Do **NOT** create `README.md` or documentation files unless explicitly requested.
@@ -230,4 +276,4 @@ bun run build # Build all packages
230
 
231
  ---
232
 
233
- _Last updated: 2026-05-14_
 
93
  - ❌ **Do not assume Prisma**. Database layer is **Drizzle ORM**.
94
  - ❌ **Do not hardcode API keys** in source code. AI keys are user-managed via the Settings UI.
95
  - ❌ **Do not add global CSS** in `apps/web` without checking `packages/ui/src/styles/globals.css` for existing design tokens and CSS variables.
96
+ - ❌ **Do not skip heading levels**. After `<h1>`, the next heading must be `<h2>`, not `<h3>` (or lower). Never have two `<h1>` on one page.
97
+ - ❌ **Do not create custom modal/dialog components**. Use shadcn `<Dialog>` from `@labas/ui/components/dialog` β€” it provides built-in focus trap, escape key, scroll lock, and ARIA.
98
+ - ❌ **Do not use `any`** in frontend TypeScript. Enforced by eslint `@typescript-eslint/no-explicit-any: "error"` in `apps/web/eslint.config.mjs`.
99
 
100
  ---
101
 
 
112
  npx shadcn@latest add accordion dialog -c packages/ui
113
  ```
114
  - App-specific blocks (non-shared) should be added directly in `apps/web/src/components/`.
115
+ - DevTools (TanStack Router, React Query) are **production-gated** via `import.meta.env.DEV` β€” do not remove the condition.
116
+ - App-level error boundary is in `__root.tsx` using `<ErrorFallback>` β€” route-level error boundaries via `errorComponent` route option.
117
+ - Lazy-loaded route components use the `.lazy.tsx` pattern: route file is thin, heavy component lives in `components/routes/*Page.tsx` wrapped in `Suspense`.
118
+
119
+ ---
120
+
121
+ ## 5b. Frontend Accessibility Conventions
122
+
123
+ - **Skip link**: Root layout in `__root.tsx` has a hidden `<SkipLink>` that appears on Tab press, linking to `#main-content` on the `<main>` element.
124
+ - **Icon-only buttons**: Every `<button>` or `<Button>` with only an icon (no visible text) **must** have `aria-label`. The icon element (`<MaterialIcon>`, `<span>`) should NOT have `aria-hidden="true"` unless decorative.
125
+ - **Heading hierarchy**: Never skip levels (`h1 β†’ h2 β†’ h3`, never `h1 β†’ h3`). Exactly one `<h1>` per page. Heading level corresponds to semantic nesting, not visual size.
126
+ - **Form labels**: Every `<Input>`, `<select>`, `<textarea>` needs an associated `<Label>` (with `htmlFor` + `id`) OR an `aria-label` attribute. Search inputs, OTP digits, and icon-only filter buttons are common offenders.
127
+ - **Dialogs**: All modals use the shadcn `<Dialog>` component (from `@labas/ui/components/dialog`), which provides:
128
+ - Focus trap inside dialog
129
+ - Escape key to close
130
+ - Scroll lock on body
131
+ - Focus restoration to trigger element on close
132
+ - ARIA `role="dialog"`, `aria-modal`, `aria-labelledby`
133
+ - **Custom tabs/accordions**: Must have proper ARIA roles (`role="tablist"`, `role="tab"`, `aria-selected`, `tabindex` for roving tabindex) and arrow key handlers. Prefer shadcn `<Tabs>` where possible; use manual ARIA + `handleRovingKeyDown` helper only when tabs have close buttons or other non-standard elements.
134
 
135
  ---
136
 
 
209
  - Routers live in `packages/api/src/routers/`.
210
  - Register new routers in `packages/api/src/routers/index.ts`.
211
 
212
+ ## 11. Admin Routes & DataTable
213
+
214
+ ### Admin Route Structure
215
+ - Admin routes are flat files: `routes/admin.users.tsx`, `routes/admin.credits.tsx`, etc.
216
+ - Wrapped by `routes/admin.tsx` layout shell (sidebar + admin role check).
217
+ - All admin tRPC procedures live in `packages/api/src/routers/admin.ts` and use `adminProcedure` (extends `protectedProcedure` with role check).
218
+ - Mutations use `audit()` helper for audit logging to `adminAuditLog` table.
219
+
220
+ ### DataTable Component (Design Reference)
221
+ - Admin table routes should eventually use a `<DataTable<T>>` component from `components/admin/DataTable.tsx`.
222
+ - API design: generic `<T>`, column defs with `accessorKey`/`accessorFn`/`cell` render prop, `actions` render prop, opt-in pagination, server-side sorting.
223
+ - Search bars, filter pills, and status pill buttons live **above** the DataTable in the route component (not inside it).
224
+ - **Do NOT** use DataTable for card-based layouts (e.g., Featured editor's pick) β€” keep card layouts as-is.
225
+
226
+ ### Current Migration Status
227
+ - `admin.users.tsx` β€” table-based, good candidate for DataTable
228
+ - `admin.jobs.tsx` β€” table-based, good candidate
229
+ - `admin.moderation.tsx` β€” table-based, good candidate
230
+ - `admin.credits.tsx` β€” transaction table, good candidate
231
+ - `admin.featured.tsx` β€” card layout, keep as-is
232
+ - `admin.index.tsx` β€” dashboard stats cards, keep as-is
233
+
234
+ ---
235
+
236
+ ## 11b. Git & Workflow
237
 
238
  - Do **NOT** run `git commit`, `git push`, `git rebase`, or force-push unless the user explicitly asks for it.
239
  - Do **NOT** create `README.md` or documentation files unless explicitly requested.
 
276
 
277
  ---
278
 
279
+ _Last updated: 2026-05-19_
DESIGN.md CHANGED
@@ -302,3 +302,89 @@ What makes Clay truly distinctive is its hover micro-animations: buttons on hove
302
  5. Hover animations are the signature β€” rotation + hard shadow, not subtle fades
303
  6. Generous radius: 24px cards, 40px sections β€” nothing looks sharp or corporate
304
  7. Three weights: 600 (headings), 500 (UI), 400 (body) β€” strict roles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  5. Hover animations are the signature β€” rotation + hard shadow, not subtle fades
303
  6. Generous radius: 24px cards, 40px sections β€” nothing looks sharp or corporate
304
  7. Three weights: 600 (headings), 500 (UI), 400 (body) β€” strict roles
305
+
306
+ ---
307
+
308
+ ## 4. Architectural Decisions (Q2 2026)
309
+
310
+ ### 4.1 Component Decomposition
311
+
312
+ | Refactored Component | Original Lines | After | Strategy |
313
+ |---|---|---|---|
314
+ | `AttemptTestView.tsx` | 589 | ~320 | Extract header (AttemptHeader), abandon/finish dialogs, floating nav |
315
+ | `use-generation-jobs.ts` | 391 | ~120 (facade) | Extract `use-job-shared.ts`, `use-job-state.ts`, `use-polling-transport.ts` |
316
+
317
+ ### 4.2 Lazy Loading Architecture
318
+
319
+ Heavy route components extracted to `apps/web/src/components/routes/*Page.tsx` and loaded via `.lazy.tsx`:
320
+ - `BankPage`, `GeneratePage`, `LandingPage`, `PackagesPage`, `SettingsPage`
321
+ - Libraries `recharts` and `react-joyride` wrapped in lazy bundles (`ChartsBundle.tsx`, `TourGuideImpl.tsx`)
322
+ - Each chunk loaded on-demand via React.lazy + Suspense
323
+
324
+ ### 4.3 Semantic CSS Token Mapping
325
+
326
+ All `packages/ui` primitives use semantic tokens; zero app-specific CSS vars in primitives:
327
+ - `--primary` β†’ `--matcha-600` (green/action)
328
+ - `--secondary` β†’ `--clay-black` (black/neutral)
329
+ - Theme lives in `packages/ui/src/styles/globals.css`
330
+ - App (`apps/web`) imports only `@import "@labas/ui/globals.css"` β€” no `index.css` overrides
331
+
332
+ ### 4.4 Accessibility Decisions
333
+
334
+ | Pattern | Implementation | Files |
335
+ |---------|---------------|-------|
336
+ | Focus trap + escape | shadcn `<Dialog>` (adapter: Base UI) | 6 modals in `components/` |
337
+ | Keyboard tabs | `handleRovingKeyDown` helper + ARIA | `FilterBar.tsx`, `GeneratePage.tsx`, `FloatingNav.tsx` |
338
+ | shadcn Tabs | `<Tabs>` primitive for simple tab groups | `PackagesPage.tsx`, `admin.featured.tsx` |
339
+ | Skip link | `<SkipLink>` component, `#main-content` | `__root.tsx` |
340
+ | Icon buttons | `aria-label` required, `title` insufficient | 16 buttons across 6 files |
341
+ | Heading hierarchy | `h1 β†’ h2 β†’ h3`, never skip | Audited ~15 files |
342
+
343
+ ### 4.5 Admin DataTable API (Design)
344
+
345
+ ```ts
346
+ interface ColumnDef<T> {
347
+ id: string;
348
+ header: string;
349
+ accessorKey?: keyof T & string;
350
+ accessorFn?: (row: T) => ReactNode;
351
+ cell?: (props: { row: T; value: ReactNode }) => ReactNode;
352
+ sortable?: boolean;
353
+ size?: string;
354
+ }
355
+
356
+ interface DataTableProps<T> {
357
+ data: T[];
358
+ columns: ColumnDef<T>[];
359
+ keyExtractor: (row: T) => string | number;
360
+ isLoading?: boolean;
361
+ emptyMessage?: string;
362
+ page?: number;
363
+ totalPages?: number;
364
+ onPageChange?: (p: number) => void;
365
+ sortColumn?: string;
366
+ sortDirection?: "asc" | "desc";
367
+ onSort?: (column: string) => void;
368
+ actions?: (row: T) => ReactNode;
369
+ }
370
+ ```
371
+
372
+ - Generic `<T>` for full type safety
373
+ - Opt-in pagination (reuses existing `Pagination` component)
374
+ - Server-side sorting via `sortable` + `onSort` callback
375
+ - Search bars/filter pills sit **above** DataTable, not inside it
376
+ - Card layouts (Featured editor's pick) excluded β€” DataTable is table-only
377
+
378
+ ### 4.6 Job Transport Abstraction
379
+
380
+ Polling implemented as `JobTransport` interface in `packages/ai/src/agentic.ts`:
381
+ - Currently: in-memory `Map` for rate limiting
382
+ - Designed to swap to WebSocket/SSE without changing callers
383
+ - Exported: `checkRateLimit(userId)`, `JobTransport` type
384
+
385
+ ### 4.7 Type Safety
386
+
387
+ - `no-explicit-any: "error"` enforced via `apps/web/eslint.config.mjs`
388
+ - Shared frontend types in `apps/web/src/lib/types.ts` β€” all fields optional (permissive) to accept diverse tRPC shapes without `any`
389
+ - tRPC errors caught as `unknown`, narrowed via `getErrorMessage(e)` helper
390
+ - Zero `any` violations across entire frontend after Q2 2026 sprint
apps/web/eslint.config.mjs ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import eslint from "@eslint/js";
2
+ import tseslint from "typescript-eslint";
3
+
4
+ export default tseslint.config(
5
+ eslint.configs.recommended,
6
+ ...tseslint.configs.recommended,
7
+ {
8
+ files: ["src/**/*.{ts,tsx}"],
9
+ rules: {
10
+ "@typescript-eslint/no-explicit-any": "error",
11
+ "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
12
+ },
13
+ },
14
+ {
15
+ ignores: ["dist", "node_modules", "src/routeTree.gen.ts"],
16
+ },
17
+ );
apps/web/package.json CHANGED
@@ -11,6 +11,7 @@
11
  "start": "vite",
12
  "test": "bun test",
13
  "check-types": "vite build && tsc --noEmit",
 
14
  "generate-pwa-assets": "pwa-assets-generator"
15
  },
16
  "dependencies": {
@@ -41,6 +42,7 @@
41
  "zod": "catalog:"
42
  },
43
  "devDependencies": {
 
44
  "@labas/config": "workspace:*",
45
  "@tanstack/react-query-devtools": "^5.91.1",
46
  "@tanstack/react-router-devtools": "^1.166.13",
@@ -50,9 +52,11 @@
50
  "@types/react-dom": "catalog:",
51
  "@vite-pwa/assets-generator": "^1.0.2",
52
  "@vitejs/plugin-react": "^6.0.1",
 
53
  "postcss": "^8.5.10",
54
  "tailwindcss": "^4.2.2",
55
  "typescript": "catalog:",
 
56
  "vite": "^8.0.8"
57
  }
58
  }
 
11
  "start": "vite",
12
  "test": "bun test",
13
  "check-types": "vite build && tsc --noEmit",
14
+ "lint": "eslint src",
15
  "generate-pwa-assets": "pwa-assets-generator"
16
  },
17
  "dependencies": {
 
42
  "zod": "catalog:"
43
  },
44
  "devDependencies": {
45
+ "@eslint/js": "^10.0.1",
46
  "@labas/config": "workspace:*",
47
  "@tanstack/react-query-devtools": "^5.91.1",
48
  "@tanstack/react-router-devtools": "^1.166.13",
 
52
  "@types/react-dom": "catalog:",
53
  "@vite-pwa/assets-generator": "^1.0.2",
54
  "@vitejs/plugin-react": "^6.0.1",
55
+ "eslint": "^10.4.0",
56
  "postcss": "^8.5.10",
57
  "tailwindcss": "^4.2.2",
58
  "typescript": "catalog:",
59
+ "typescript-eslint": "^8.59.4",
60
  "vite": "^8.0.8"
61
  }
62
  }
apps/web/src/components/GettingStartedCard.tsx CHANGED
@@ -73,6 +73,7 @@ export function GettingStartedCard() {
73
  </div>
74
  <button
75
  onClick={dismiss}
 
76
  className="text-[var(--warm-silver)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
77
  >
78
  <MaterialIcon name="close" className="text-xl" />
 
73
  </div>
74
  <button
75
  onClick={dismiss}
76
+ aria-label="Tutup"
77
  className="text-[var(--warm-silver)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
78
  >
79
  <MaterialIcon name="close" className="text-xl" />
apps/web/src/components/TourGuide.tsx CHANGED
@@ -1,67 +1,20 @@
1
- import { useState, useEffect } from "react";
2
- import { Joyride, type Step, type EventData } from "react-joyride";
3
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
4
 
5
- const joyrideOptions = {
6
- primaryColor: "var(--matcha-600)",
7
- textColor: "var(--clay-black)",
8
- backgroundColor: "var(--pure-white)",
9
- arrowColor: "var(--pure-white)",
10
- overlayColor: "rgba(0,0,0,0.4)",
11
- };
12
 
13
- const joyrideStyles = {
14
- buttonBack: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 } as any,
15
- buttonSkip: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 } as any,
16
- tooltipContainer: { textAlign: "left" } as any,
17
- tooltipContent: { fontSize: "14px", lineHeight: "1.6", padding: "8px 0" } as any,
18
- tooltipTitle: { fontSize: "18px", fontWeight: 700, fontFamily: "Manrope, sans-serif" } as any,
19
- };
20
-
21
- const joyrideLocale = { back: "Kembali", close: "Tutup", last: "Selesai", next: "Lanjut", skip: "Lewati" };
22
-
23
- // ── Global site tour ──
24
 
25
  const LS_GLOBAL = "labas-tour-completed";
26
  const TRIGGER_GLOBAL = "labas-tour-trigger";
27
 
28
- const globalSteps: Step[] = [
29
- { target: "body" as any, placement: "center", title: "Selamat Datang di Labas!", content: "Platform latihan ujian bahasa berbasis AI. Yuk, kita lihat fitur-fitur utamanya!", hideOverlay: true },
30
- { target: "[data-tour='dashboard-stats']", title: "Ringkasan Aktivitas", content: "Pantau jumlah latihan, waktu belajar, soal terjawab, dan akurasi kamu di sini.", spotlightPadding: 8 },
31
- { target: "[data-tour='nav-generate']", title: "AI Lab β€” Generate Soal", content: "Buat soal latihan sendiri pakai AI. Pilih jenis ujian, section, format, dan topik.", spotlightPadding: 4 },
32
- { target: "[data-tour='nav-bank']", title: "Bank Soal β€” Buat Paket", content: "Atur soal-soal kamu jadi paket latihan dari bank soal.", spotlightPadding: 4 },
33
- { target: "[data-tour='nav-packages']", title: "Paket Soal β€” Mulai Latihan", content: "Temukan paket soal dari komunitas atau buatan sendiri.", spotlightPadding: 4 },
34
- { target: "[data-tour='nav-analytics']", title: "Analytics β€” Evaluasi", content: "Lihat analitik mendalam: skor, tren, dan rekomendasi belajar.", spotlightPadding: 4 },
35
- { target: "body" as any, placement: "center", title: "Siap Belajar?", content: "Setiap halaman punya panduan sendiri. Cari tombol ? di pojok kanan bawah halaman!", hideOverlay: true },
36
- ];
37
-
38
  export function TourGuide() {
39
- const [run, setRun] = useState(false);
40
-
41
- useEffect(() => {
42
- const onTrigger = () => { localStorage.removeItem(LS_GLOBAL); setRun(true); };
43
- window.addEventListener(TRIGGER_GLOBAL, onTrigger);
44
-
45
- const completed = localStorage.getItem(LS_GLOBAL);
46
- if (!completed) {
47
- const timer = setTimeout(() => setRun(true), 800);
48
- return () => { clearTimeout(timer); window.removeEventListener(TRIGGER_GLOBAL, onTrigger); };
49
- }
50
-
51
- return () => window.removeEventListener(TRIGGER_GLOBAL, onTrigger);
52
- }, []);
53
-
54
- const handleEvent = (data: EventData) => {
55
- const { action, status } = data;
56
- if (status === "finished" || status === "skipped") { localStorage.setItem(LS_GLOBAL, "true"); setRun(false); }
57
- if (action === "close" || action === "skip") setRun(false);
58
- };
59
-
60
  return (
61
- <Joyride steps={globalSteps} run={run} continuous
62
- options={{ ...joyrideOptions, showProgress: true, buttons: ["back", "primary", "skip"] }}
63
- locale={joyrideLocale} styles={joyrideStyles} onEvent={handleEvent}
64
- />
65
  );
66
  }
67
 
@@ -78,32 +31,10 @@ interface PageTourProps {
78
  }
79
 
80
  export function PageTour({ storageKey, steps, autoDelay }: PageTourProps) {
81
- const [run, setRun] = useState(false);
82
-
83
- useEffect(() => {
84
- const onTrigger = () => { localStorage.removeItem(storageKey); setRun(true); };
85
- window.addEventListener(storageKey, onTrigger);
86
-
87
- const completed = localStorage.getItem(storageKey);
88
- if (!completed && autoDelay !== undefined) {
89
- const timer = setTimeout(() => setRun(true), autoDelay);
90
- return () => { clearTimeout(timer); window.removeEventListener(storageKey, onTrigger); };
91
- }
92
-
93
- return () => window.removeEventListener(storageKey, onTrigger);
94
- }, []);
95
-
96
- const handleEvent = (data: EventData) => {
97
- const { action, status } = data;
98
- if (status === "finished" || status === "skipped") { localStorage.setItem(storageKey, "true"); setRun(false); }
99
- if (action === "close" || action === "skip") setRun(false);
100
- };
101
-
102
  return (
103
- <Joyride steps={steps} run={run} continuous
104
- options={{ ...joyrideOptions, showProgress: true, buttons: ["back", "primary", "skip"] }}
105
- locale={joyrideLocale} styles={joyrideStyles} onEvent={handleEvent}
106
- />
107
  );
108
  }
109
 
 
1
+ import { Suspense, lazy } from "react";
 
2
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+ import type { Step } from "react-joyride";
4
 
5
+ const TourGuideImpl = lazy(() => import("./TourGuideImpl").then((m) => ({ default: m.TourGuideImpl })));
6
+ const PageTourImpl = lazy(() => import("./TourGuideImpl").then((m) => ({ default: m.PageTourImpl })));
 
 
 
 
 
7
 
8
+ export type { Step };
 
 
 
 
 
 
 
 
 
 
9
 
10
  const LS_GLOBAL = "labas-tour-completed";
11
  const TRIGGER_GLOBAL = "labas-tour-trigger";
12
 
 
 
 
 
 
 
 
 
 
 
13
  export function TourGuide() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  return (
15
+ <Suspense fallback={null}>
16
+ <TourGuideImpl />
17
+ </Suspense>
 
18
  );
19
  }
20
 
 
31
  }
32
 
33
  export function PageTour({ storageKey, steps, autoDelay }: PageTourProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  return (
35
+ <Suspense fallback={null}>
36
+ <PageTourImpl storageKey={storageKey} steps={steps} autoDelay={autoDelay} />
37
+ </Suspense>
 
38
  );
39
  }
40
 
apps/web/src/components/TourGuideImpl.tsx ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, type CSSProperties } from "react";
2
+ import { Joyride, type Step, type EventData } from "react-joyride";
3
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
4
+
5
+ const joyrideOptions = {
6
+ primaryColor: "var(--matcha-600)",
7
+ textColor: "var(--clay-black)",
8
+ backgroundColor: "var(--pure-white)",
9
+ arrowColor: "var(--pure-white)",
10
+ overlayColor: "rgba(0,0,0,0.4)",
11
+ };
12
+
13
+ const joyrideStyles: Record<string, CSSProperties> = {
14
+ buttonBack: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 },
15
+ buttonSkip: { color: "var(--warm-charcoal)", fontSize: "14px", fontWeight: 500 },
16
+ tooltipContainer: { textAlign: "left" },
17
+ tooltipContent: { fontSize: "14px", lineHeight: "1.6", padding: "8px 0" },
18
+ tooltipTitle: { fontSize: "18px", fontWeight: 700, fontFamily: "Manrope, sans-serif" },
19
+ };
20
+
21
+ const joyrideLocale = { back: "Kembali", close: "Tutup", last: "Selesai", next: "Lanjut", skip: "Lewati" };
22
+
23
+ export type { Step, EventData };
24
+
25
+ // ── Global site tour ──
26
+
27
+ const LS_GLOBAL = "labas-tour-completed";
28
+ const TRIGGER_GLOBAL = "labas-tour-trigger";
29
+
30
+ const globalSteps: Step[] = [
31
+ { target: "body", placement: "center", title: "Selamat Datang di Labas!", content: "Platform latihan ujian bahasa berbasis AI. Yuk, kita lihat fitur-fitur utamanya!", hideOverlay: true },
32
+ { target: "[data-tour='dashboard-stats']", title: "Ringkasan Aktivitas", content: "Pantau jumlah latihan, waktu belajar, soal terjawab, dan akurasi kamu di sini.", spotlightPadding: 8 },
33
+ { target: "[data-tour='nav-generate']", title: "AI Lab β€” Generate Soal", content: "Buat soal latihan sendiri pakai AI. Pilih jenis ujian, section, format, dan topik.", spotlightPadding: 4 },
34
+ { target: "[data-tour='nav-bank']", title: "Bank Soal β€” Buat Paket", content: "Atur soal-soal kamu jadi paket latihan dari bank soal.", spotlightPadding: 4 },
35
+ { target: "[data-tour='nav-packages']", title: "Paket Soal β€” Mulai Latihan", content: "Temukan paket soal dari komunitas atau buatan sendiri.", spotlightPadding: 4 },
36
+ { target: "[data-tour='nav-analytics']", title: "Analytics β€” Evaluasi", content: "Lihat analitik mendalam: skor, tren, dan rekomendasi belajar.", spotlightPadding: 4 },
37
+ { target: "body", placement: "center", title: "Siap Belajar?", content: "Setiap halaman punya panduan sendiri. Cari tombol ? di pojok kanan bawah halaman!", hideOverlay: true },
38
+ ];
39
+
40
+ export function TourGuideImpl() {
41
+ const [run, setRun] = useState(false);
42
+
43
+ useEffect(() => {
44
+ const onTrigger = () => { localStorage.removeItem(LS_GLOBAL); setRun(true); };
45
+ window.addEventListener(TRIGGER_GLOBAL, onTrigger);
46
+
47
+ const completed = localStorage.getItem(LS_GLOBAL);
48
+ if (!completed) {
49
+ const timer = setTimeout(() => setRun(true), 800);
50
+ return () => { clearTimeout(timer); window.removeEventListener(TRIGGER_GLOBAL, onTrigger); };
51
+ }
52
+
53
+ return () => window.removeEventListener(TRIGGER_GLOBAL, onTrigger);
54
+ }, []);
55
+
56
+ const handleEvent = (data: EventData) => {
57
+ const { action, status } = data;
58
+ if (status === "finished" || status === "skipped") { localStorage.setItem(LS_GLOBAL, "true"); setRun(false); }
59
+ if (action === "close" || action === "skip") setRun(false);
60
+ };
61
+
62
+ return (
63
+ <Joyride steps={globalSteps} run={run} continuous
64
+ options={{ ...joyrideOptions, showProgress: true, buttons: ["back", "primary", "skip"] }}
65
+ locale={joyrideLocale} styles={joyrideStyles} onEvent={handleEvent}
66
+ />
67
+ );
68
+ }
69
+
70
+ // ── Page-specific tour ──
71
+
72
+ interface PageTourProps {
73
+ storageKey: string;
74
+ steps: Step[];
75
+ autoDelay?: number;
76
+ }
77
+
78
+ export function PageTourImpl({ storageKey, steps, autoDelay }: PageTourProps) {
79
+ const [run, setRun] = useState(false);
80
+
81
+ useEffect(() => {
82
+ const onTrigger = () => { localStorage.removeItem(storageKey); setRun(true); };
83
+ window.addEventListener(storageKey, onTrigger);
84
+
85
+ const completed = localStorage.getItem(storageKey);
86
+ if (!completed && autoDelay !== undefined) {
87
+ const timer = setTimeout(() => setRun(true), autoDelay);
88
+ return () => { clearTimeout(timer); window.removeEventListener(storageKey, onTrigger); };
89
+ }
90
+
91
+ return () => window.removeEventListener(storageKey, onTrigger);
92
+ }, []);
93
+
94
+ const handleEvent = (data: EventData) => {
95
+ const { action, status } = data;
96
+ if (status === "finished" || status === "skipped") { localStorage.setItem(storageKey, "true"); setRun(false); }
97
+ if (action === "close" || action === "skip") setRun(false);
98
+ };
99
+
100
+ return (
101
+ <Joyride steps={steps} run={run} continuous
102
+ options={{ ...joyrideOptions, showProgress: true, buttons: ["back", "primary", "skip"] }}
103
+ locale={joyrideLocale} styles={joyrideStyles} onEvent={handleEvent}
104
+ />
105
+ );
106
+ }
apps/web/src/components/analytics/BreakdownCharts.tsx CHANGED
@@ -13,7 +13,7 @@ import { formatLabel } from "@/lib/format";
13
  import { formatTime } from "@/lib/time";
14
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
15
 
16
- interface ExamTypeItem {
17
  examTypeId: string;
18
  examTypeName: string;
19
  accuracyPct: number;
@@ -22,7 +22,7 @@ interface ExamTypeItem {
22
  attempts: number;
23
  }
24
 
25
- interface SectionTypeItem {
26
  sectionTypeId: string;
27
  sectionTypeName: string;
28
  accuracyPct: number;
@@ -31,7 +31,7 @@ interface SectionTypeItem {
31
  attempts: number;
32
  }
33
 
34
- interface FormatItem {
35
  format: string;
36
  accuracyPct: number;
37
  avgTimeSpentSec: number;
 
13
  import { formatTime } from "@/lib/time";
14
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
15
 
16
+ export interface ExamTypeItem {
17
  examTypeId: string;
18
  examTypeName: string;
19
  accuracyPct: number;
 
22
  attempts: number;
23
  }
24
 
25
+ export interface SectionTypeItem {
26
  sectionTypeId: string;
27
  sectionTypeName: string;
28
  accuracyPct: number;
 
31
  attempts: number;
32
  }
33
 
34
+ export interface FormatItem {
35
  format: string;
36
  accuracyPct: number;
37
  avgTimeSpentSec: number;
apps/web/src/components/analytics/ChartsBundle.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ScoreTrendChart } from "./ScoreTrendChart";
2
+ import { BreakdownCharts, type ExamTypeItem, type SectionTypeItem, type FormatItem } from "./BreakdownCharts";
3
+ import { TimeAnalyticsPanel, type SectionTimeItem, type FormatTimeItem, type TimeTrendItem } from "./TimeAnalyticsPanel";
4
+
5
+ interface ChartsBundleProps {
6
+ trend: { date: string; attempts: number; avgScorePct: number }[] | undefined;
7
+ byExamType: ExamTypeItem[] | undefined;
8
+ bySectionType: SectionTypeItem[] | undefined;
9
+ byFormat: FormatItem[] | undefined;
10
+ sectionTime: SectionTimeItem[] | undefined;
11
+ formatTimeData: FormatTimeItem[] | undefined;
12
+ timeTrend: TimeTrendItem[] | undefined;
13
+ }
14
+
15
+ export default function ChartsBundle({
16
+ trend,
17
+ byExamType,
18
+ bySectionType,
19
+ byFormat,
20
+ sectionTime,
21
+ formatTimeData,
22
+ timeTrend,
23
+ }: ChartsBundleProps) {
24
+ return (
25
+ <>
26
+ <div className="mb-8">
27
+ <ScoreTrendChart data={trend} />
28
+ </div>
29
+ <div className="mb-8">
30
+ <BreakdownCharts
31
+ byExamType={byExamType}
32
+ bySectionType={bySectionType}
33
+ byFormat={byFormat}
34
+ />
35
+ </div>
36
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
37
+ <TimeAnalyticsPanel
38
+ sectionTime={sectionTime}
39
+ formatTimeData={formatTimeData}
40
+ timeTrend={timeTrend}
41
+ />
42
+ </div>
43
+ </>
44
+ );
45
+ }
apps/web/src/components/analytics/TimeAnalyticsPanel.tsx CHANGED
@@ -11,19 +11,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@labas/ui/components/c
11
  import { formatTime } from "@/lib/time";
12
  import { formatLabel } from "@/lib/format";
13
 
14
- interface SectionTimeItem {
15
  sectionTypeName: string;
16
  avgTimeSpentSec: number;
17
  totalTimeSpentSec: number;
18
  }
19
 
20
- interface FormatTimeItem {
21
  format: string;
22
  avgTimeSpentSec: number;
23
  totalTimeSpentSec: number;
24
  }
25
 
26
- interface TimeTrendItem {
27
  date: string;
28
  avgTimeSpentSec: number;
29
  }
 
11
  import { formatTime } from "@/lib/time";
12
  import { formatLabel } from "@/lib/format";
13
 
14
+ export interface SectionTimeItem {
15
  sectionTypeName: string;
16
  avgTimeSpentSec: number;
17
  totalTimeSpentSec: number;
18
  }
19
 
20
+ export interface FormatTimeItem {
21
  format: string;
22
  avgTimeSpentSec: number;
23
  totalTimeSpentSec: number;
24
  }
25
 
26
+ export interface TimeTrendItem {
27
  date: string;
28
  avgTimeSpentSec: number;
29
  }
apps/web/src/components/attempt/OptionDisplay.tsx CHANGED
@@ -2,18 +2,28 @@ import { MaterialIcon } from "@/components/ui/MaterialIcon";
2
 
3
  import { MCQ_FORMATS, McqFormat } from "@/lib/question-formats";
4
 
 
 
 
 
 
 
 
 
 
 
5
  interface OptionDisplayProps {
6
  format: string;
7
- options: any[];
8
  correctAnswer: string;
9
  userAnswer: string;
10
  }
11
 
12
  export function OptionDisplay({ format, options, correctAnswer, userAnswer }: OptionDisplayProps) {
13
- if (!options || options.length === 0) return null;
14
 
15
  if (format === "matching_pairs") {
16
- return <MatchingPairsOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
17
  }
18
 
19
  if (format === "true_false_not_given") {
@@ -24,8 +34,8 @@ export function OptionDisplay({ format, options, correctAnswer, userAnswer }: Op
24
  return <TriStateOptions choices={["YES", "NO", "NOT_GIVEN"]} labels={{ YES: "Yes", NO: "No", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
25
  }
26
 
27
- if (MCQ_FORMATS.includes(format as McqFormat)) {
28
- return <McqOptions options={options} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
29
  }
30
 
31
  return null;
@@ -114,7 +124,7 @@ function TriStateOptions({ choices, labels, correctAnswer, userAnswer }: { choic
114
  );
115
  }
116
 
117
- function MatchingPairsOptions({ options, correctAnswer, userAnswer }: { options: { left: string; right?: string }[]; correctAnswer: string; userAnswer: string }) {
118
  const parseMapping = (s: string): Map<string, string> => {
119
  const map = new Map();
120
  if (!s) return map;
 
2
 
3
  import { MCQ_FORMATS, McqFormat } from "@/lib/question-formats";
4
 
5
+ interface McqOptionItem {
6
+ key: string;
7
+ text: string;
8
+ }
9
+
10
+ interface MatchingOptionItem {
11
+ left: string;
12
+ right?: string;
13
+ }
14
+
15
  interface OptionDisplayProps {
16
  format: string;
17
+ options: unknown;
18
  correctAnswer: string;
19
  userAnswer: string;
20
  }
21
 
22
  export function OptionDisplay({ format, options, correctAnswer, userAnswer }: OptionDisplayProps) {
23
+ if (!Array.isArray(options) || options.length === 0) return null;
24
 
25
  if (format === "matching_pairs") {
26
+ return <MatchingPairsOptions options={options as MatchingOptionItem[]} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
27
  }
28
 
29
  if (format === "true_false_not_given") {
 
34
  return <TriStateOptions choices={["YES", "NO", "NOT_GIVEN"]} labels={{ YES: "Yes", NO: "No", NOT_GIVEN: "Not Given" }} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
35
  }
36
 
37
+ if ((MCQ_FORMATS as readonly string[]).includes(format)) {
38
+ return <McqOptions options={options as McqOptionItem[]} correctAnswer={correctAnswer} userAnswer={userAnswer} />;
39
  }
40
 
41
  return null;
 
124
  );
125
  }
126
 
127
+ function MatchingPairsOptions({ options, correctAnswer, userAnswer }: { options: MatchingOptionItem[]; correctAnswer: string; userAnswer: string }) {
128
  const parseMapping = (s: string): Map<string, string> => {
129
  const map = new Map();
130
  if (!s) return map;
apps/web/src/components/attempt/QuestionReviewCard.tsx CHANGED
@@ -5,13 +5,14 @@ import { Button } from "@labas/ui/components/button";
5
  import { Input } from "@labas/ui/components/input";
6
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
7
  import { formatTime } from "@/lib/time";
 
8
  import { OptionDisplay } from "./OptionDisplay";
9
 
10
  interface QuestionReviewCardProps {
11
- q: any;
12
  secIdx: number;
13
  qIdx: number;
14
- ans: any;
15
  userId?: string;
16
  isExpanded: boolean;
17
  onToggleExpand: () => void;
@@ -97,7 +98,7 @@ export function QuestionReviewCard({
97
  </p>
98
  <div className="flex items-center gap-2 mt-1">
99
  <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
100
- {q.format.replace(/_/g, " ")}
101
  </span>
102
  {ans?.timeSpentSec != null && (
103
  <span className="text-[10px] text-[var(--warm-silver)] flex items-center gap-0.5">
@@ -135,9 +136,9 @@ export function QuestionReviewCard({
135
  {/* Interactive Option Display */}
136
  {hasOptions && (
137
  <OptionDisplay
138
- format={q.format}
139
  options={q.options}
140
- correctAnswer={q.correctAnswer}
141
  userAnswer={userAnswer}
142
  />
143
  )}
 
5
  import { Input } from "@labas/ui/components/input";
6
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
7
  import { formatTime } from "@/lib/time";
8
+ import type { Question } from "@/lib/types";
9
  import { OptionDisplay } from "./OptionDisplay";
10
 
11
  interface QuestionReviewCardProps {
12
+ q: Question;
13
  secIdx: number;
14
  qIdx: number;
15
+ ans: { questionId: string; userAnswer?: string; answer?: string; isCorrect?: boolean | null; partialScore?: number | null; timeSpentSec?: number } | null;
16
  userId?: string;
17
  isExpanded: boolean;
18
  onToggleExpand: () => void;
 
98
  </p>
99
  <div className="flex items-center gap-2 mt-1">
100
  <span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
101
+ {(q.format ?? "").replace(/_/g, " ")}
102
  </span>
103
  {ans?.timeSpentSec != null && (
104
  <span className="text-[10px] text-[var(--warm-silver)] flex items-center gap-0.5">
 
136
  {/* Interactive Option Display */}
137
  {hasOptions && (
138
  <OptionDisplay
139
+ format={q.format ?? ""}
140
  options={q.options}
141
+ correctAnswer={q.correctAnswer ?? ""}
142
  userAnswer={userAnswer}
143
  />
144
  )}
apps/web/src/components/bank/AutoBundleModal.tsx CHANGED
@@ -1,6 +1,12 @@
1
  import { useState } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
  import { Button } from "@labas/ui/components/button";
 
 
 
 
 
 
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
 
6
  export function AutoBundleModal({
@@ -32,24 +38,13 @@ export function AutoBundleModal({
32
  const [sortOrder, setSortOrder] = useState<"random" | "difficulty">("random");
33
 
34
  return (
35
- <div
36
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
37
- onClick={(e) => {
38
- if (e.target === e.currentTarget) onClose();
39
- }}
40
- >
41
- <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
42
- <div className="flex items-center justify-between mb-6">
43
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
44
  Auto Bundle
45
- </h2>
46
- <button
47
- onClick={onClose}
48
- className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
49
- >
50
- <MaterialIcon name="close" className="text-[var(--clay-black)]" />
51
- </button>
52
- </div>
53
 
54
  <div className="flex items-center gap-2 mb-4">
55
  <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
@@ -180,7 +175,7 @@ export function AutoBundleModal({
180
  {isPending ? "Membuat..." : "Buat Paket"}
181
  </Button>
182
  </div>
183
- </div>
184
- </div>
185
  );
186
  }
 
1
  import { useState } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
  import { Button } from "@labas/ui/components/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@labas/ui/components/dialog";
10
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
11
 
12
  export function AutoBundleModal({
 
38
  const [sortOrder, setSortOrder] = useState<"random" | "difficulty">("random");
39
 
40
  return (
41
+ <Dialog open onOpenChange={(v) => !v && onClose()}>
42
+ <DialogContent className="max-w-lg">
43
+ <DialogHeader>
44
+ <DialogTitle className="text-2xl font-headline font-bold text-[var(--clay-black)]">
 
 
 
 
 
45
  Auto Bundle
46
+ </DialogTitle>
47
+ </DialogHeader>
 
 
 
 
 
 
48
 
49
  <div className="flex items-center gap-2 mb-4">
50
  <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
 
175
  {isPending ? "Membuat..." : "Buat Paket"}
176
  </Button>
177
  </div>
178
+ </DialogContent>
179
+ </Dialog>
180
  );
181
  }
apps/web/src/components/bank/BundleSidebar.tsx CHANGED
@@ -7,9 +7,9 @@ interface BundleItem {
7
  id: string;
8
  questionText?: string;
9
  title?: string;
10
- examTypeName?: string;
11
  format?: string;
12
- sectionTypeName?: string;
13
  }
14
 
15
  interface BundleSidebarProps {
@@ -82,7 +82,7 @@ export function BundleSidebar({
82
  Belum ada yang dipilih
83
  </div>
84
  ) : (
85
- activeBundle.map((item: any, idx: number) => {
86
  const isQ = mode === "soal";
87
  return (
88
  <div
@@ -93,17 +93,18 @@ export function BundleSidebar({
93
  <MaterialIcon name={isQ ? "quiz" : "folder_open"} className="text-sm" />
94
  </div>
95
  <div className="flex-1 min-w-0">
96
- <h5 className="text-sm font-bold text-[var(--clay-black)] truncate">
97
  {isQ ? item.questionText : item.title}
98
- </h5>
99
  <p className="text-xs text-[var(--warm-charcoal)]">
100
  {isQ
101
- ? `${item.examTypeName} \u00B7 ${formatLabel(item.format)}`
102
  : `${item.examTypeName} \u00B7 ${item.sectionTypeName}`}
103
  </p>
104
  </div>
105
  <button
106
  onClick={() => onRemoveFromBundle(item.id, isQ ? "question" : "section")}
 
107
  className="text-[var(--warm-silver)] hover:text-[var(--pomegranate-400)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0 cursor-pointer"
108
  >
109
  <MaterialIcon name="delete" className="text-sm" />
 
7
  id: string;
8
  questionText?: string;
9
  title?: string;
10
+ examTypeName?: string | null;
11
  format?: string;
12
+ sectionTypeName?: string | null;
13
  }
14
 
15
  interface BundleSidebarProps {
 
82
  Belum ada yang dipilih
83
  </div>
84
  ) : (
85
+ activeBundle.map((item, idx: number) => {
86
  const isQ = mode === "soal";
87
  return (
88
  <div
 
93
  <MaterialIcon name={isQ ? "quiz" : "folder_open"} className="text-sm" />
94
  </div>
95
  <div className="flex-1 min-w-0">
96
+ <h4 className="text-sm font-bold text-[var(--clay-black)] truncate">
97
  {isQ ? item.questionText : item.title}
98
+ </h4>
99
  <p className="text-xs text-[var(--warm-charcoal)]">
100
  {isQ
101
+ ? `${item.examTypeName} \u00B7 ${formatLabel(item.format ?? "")}`
102
  : `${item.examTypeName} \u00B7 ${item.sectionTypeName}`}
103
  </p>
104
  </div>
105
  <button
106
  onClick={() => onRemoveFromBundle(item.id, isQ ? "question" : "section")}
107
+ aria-label={`Hapus ${isQ ? "soal" : "section"} dari bundle`}
108
  className="text-[var(--warm-silver)] hover:text-[var(--pomegranate-400)] opacity-0 group-hover:opacity-100 transition-opacity shrink-0 cursor-pointer"
109
  >
110
  <MaterialIcon name="delete" className="text-sm" />
apps/web/src/components/bank/CreatePackageModal.tsx CHANGED
@@ -1,6 +1,12 @@
1
  import { useState } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
  import { Button } from "@labas/ui/components/button";
 
 
 
 
 
 
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
 
6
  export function CreatePackageModal({
@@ -18,30 +24,18 @@ export function CreatePackageModal({
18
  examTypeName: string;
19
  lowCount: boolean;
20
  }) {
21
- const dateStr = new Date().toLocaleDateString("id-ID", { day: "numeric", month: "short" });
22
  const [title, setTitle] = useState(`${examTypeName} Bundle β€” ${selectedCount} Soal`);
23
  const [description, setDescription] = useState("");
24
  const [isPublic, setIsPublic] = useState(false);
25
 
26
  return (
27
- <div
28
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
29
- onClick={(e) => {
30
- if (e.target === e.currentTarget) onClose();
31
- }}
32
- >
33
- <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
34
- <div className="flex items-center justify-between mb-6">
35
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
36
  Buat Paket Soal
37
- </h2>
38
- <button
39
- onClick={onClose}
40
- className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
41
- >
42
- <MaterialIcon name="close" className="text-[var(--clay-black)]" />
43
- </button>
44
- </div>
45
 
46
  <div className="flex items-center gap-2 mb-4">
47
  <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
@@ -115,7 +109,7 @@ export function CreatePackageModal({
115
  {isPending ? "Membuat..." : "Buat Paket"}
116
  </Button>
117
  </div>
118
- </div>
119
- </div>
120
  );
121
  }
 
1
  import { useState } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
  import { Button } from "@labas/ui/components/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@labas/ui/components/dialog";
10
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
11
 
12
  export function CreatePackageModal({
 
24
  examTypeName: string;
25
  lowCount: boolean;
26
  }) {
 
27
  const [title, setTitle] = useState(`${examTypeName} Bundle β€” ${selectedCount} Soal`);
28
  const [description, setDescription] = useState("");
29
  const [isPublic, setIsPublic] = useState(false);
30
 
31
  return (
32
+ <Dialog open onOpenChange={(v) => !v && onClose()}>
33
+ <DialogContent className="max-w-lg">
34
+ <DialogHeader>
35
+ <DialogTitle className="text-2xl font-headline font-bold text-[var(--clay-black)]">
 
 
 
 
 
36
  Buat Paket Soal
37
+ </DialogTitle>
38
+ </DialogHeader>
 
 
 
 
 
 
39
 
40
  <div className="flex items-center gap-2 mb-4">
41
  <span className="px-3 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
 
109
  {isPending ? "Membuat..." : "Buat Paket"}
110
  </Button>
111
  </div>
112
+ </DialogContent>
113
+ </Dialog>
114
  );
115
  }
apps/web/src/components/bank/FilterBar.tsx CHANGED
@@ -59,7 +59,12 @@ export function FilterBar({
59
  <div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-4 pb-3 space-y-3">
60
  {/* ── Tier 1: Mode tabs ── */}
61
  <div className="flex items-center justify-between gap-3">
62
- <div className="flex gap-2">
 
 
 
 
 
63
  <ModeButton active={mode === "soal"} onClick={() => onSetMode("soal")}>
64
  Dari Soal
65
  </ModeButton>
@@ -68,7 +73,12 @@ export function FilterBar({
68
  </ModeButton>
69
  </div>
70
  {mode === "soal" && (
71
- <div className="flex gap-2">
 
 
 
 
 
72
  <TabButton active={tab === "mine"} onClick={() => onSetTab("mine")}>
73
  Soal Saya
74
  </TabButton>
@@ -80,7 +90,12 @@ export function FilterBar({
80
  </div>
81
 
82
  {/* ── Tier 2: Exam type chips ── */}
83
- <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
 
 
 
 
 
84
  <ChipButton
85
  active={examType === ""}
86
  onClick={() => onSetExamType("")}
@@ -110,7 +125,12 @@ export function FilterBar({
110
 
111
  {/* ── Visibility sub-filter (only in "mine" tab) ── */}
112
  {mode === "soal" && tab === "mine" && onSetVisibility && (
113
- <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
 
 
 
 
 
114
  <VisChipButton
115
  active={visibility === "all"}
116
  onClick={() => onSetVisibility("all")}
@@ -143,6 +163,7 @@ export function FilterBar({
143
  placeholder={mode === "soal" ? "Cari soal..." : "Cari section..."}
144
  value={searchText}
145
  onChange={(e) => onSetSearch(e.target.value)}
 
146
  className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
147
  />
148
  </div>
@@ -169,6 +190,7 @@ export function FilterBar({
169
  <Button
170
  variant="outline"
171
  onClick={onClearFilters}
 
172
  className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] h-11 clay-hover cursor-pointer"
173
  >
174
  <MaterialIcon name="filter_alt_off" />
@@ -242,9 +264,32 @@ export function FilterBar({
242
  );
243
  }
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  function ModeButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
246
  return (
247
  <button
 
 
 
248
  onClick={onClick}
249
  className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all cursor-pointer ${
250
  active
@@ -260,6 +305,9 @@ function ModeButton({ active, onClick, children }: { active: boolean; onClick: (
260
  function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
261
  return (
262
  <button
 
 
 
263
  onClick={onClick}
264
  className={`px-3 py-1.5 rounded-[var(--radius-lg)] text-xs font-semibold transition-all cursor-pointer ${
265
  active
@@ -275,6 +323,9 @@ function TabButton({ active, onClick, children }: { active: boolean; onClick: ()
275
  function VisChipButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
276
  return (
277
  <button
 
 
 
278
  onClick={onClick}
279
  className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
280
  active
@@ -290,6 +341,9 @@ function VisChipButton({ active, onClick, children }: { active: boolean; onClick
290
  function ChipButton({ active, onClick, children, disabled }: { active: boolean; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {
291
  return (
292
  <button
 
 
 
293
  onClick={onClick}
294
  disabled={disabled}
295
  className={`px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-all ${
 
59
  <div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-4 pb-3 space-y-3">
60
  {/* ── Tier 1: Mode tabs ── */}
61
  <div className="flex items-center justify-between gap-3">
62
+ <div
63
+ className="flex gap-2"
64
+ role="tablist"
65
+ aria-label="Mode bank soal"
66
+ onKeyDown={(e) => handleRovingKeyDown(e, "tab")}
67
+ >
68
  <ModeButton active={mode === "soal"} onClick={() => onSetMode("soal")}>
69
  Dari Soal
70
  </ModeButton>
 
73
  </ModeButton>
74
  </div>
75
  {mode === "soal" && (
76
+ <div
77
+ className="flex gap-2"
78
+ role="tablist"
79
+ aria-label="Sumber soal"
80
+ onKeyDown={(e) => handleRovingKeyDown(e, "tab")}
81
+ >
82
  <TabButton active={tab === "mine"} onClick={() => onSetTab("mine")}>
83
  Soal Saya
84
  </TabButton>
 
90
  </div>
91
 
92
  {/* ── Tier 2: Exam type chips ── */}
93
+ <div
94
+ className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"
95
+ role="radiogroup"
96
+ aria-label="Jenis ujian"
97
+ onKeyDown={(e) => handleRovingKeyDown(e, "radio")}
98
+ >
99
  <ChipButton
100
  active={examType === ""}
101
  onClick={() => onSetExamType("")}
 
125
 
126
  {/* ── Visibility sub-filter (only in "mine" tab) ── */}
127
  {mode === "soal" && tab === "mine" && onSetVisibility && (
128
+ <div
129
+ className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"
130
+ role="radiogroup"
131
+ aria-label="Visibilitas soal"
132
+ onKeyDown={(e) => handleRovingKeyDown(e, "radio")}
133
+ >
134
  <VisChipButton
135
  active={visibility === "all"}
136
  onClick={() => onSetVisibility("all")}
 
163
  placeholder={mode === "soal" ? "Cari soal..." : "Cari section..."}
164
  value={searchText}
165
  onChange={(e) => onSetSearch(e.target.value)}
166
+ aria-label={mode === "soal" ? "Cari soal" : "Cari section"}
167
  className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
168
  />
169
  </div>
 
190
  <Button
191
  variant="outline"
192
  onClick={onClearFilters}
193
+ aria-label="Reset filter"
194
  className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] h-11 clay-hover cursor-pointer"
195
  >
196
  <MaterialIcon name="filter_alt_off" />
 
264
  );
265
  }
266
 
267
+ function handleRovingKeyDown(e: React.KeyboardEvent<HTMLDivElement>, role: string) {
268
+ const buttons = Array.from(e.currentTarget.querySelectorAll(`[role="${role}"]`)) as HTMLElement[];
269
+ const idx = buttons.findIndex((b) => b === document.activeElement);
270
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
271
+ e.preventDefault();
272
+ const next = buttons[(idx + 1) % buttons.length];
273
+ next?.focus();
274
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
275
+ e.preventDefault();
276
+ const prev = buttons[(idx - 1 + buttons.length) % buttons.length];
277
+ prev?.focus();
278
+ } else if (e.key === "Home") {
279
+ e.preventDefault();
280
+ buttons[0]?.focus();
281
+ } else if (e.key === "End") {
282
+ e.preventDefault();
283
+ buttons[buttons.length - 1]?.focus();
284
+ }
285
+ }
286
+
287
  function ModeButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
288
  return (
289
  <button
290
+ role="tab"
291
+ aria-selected={active}
292
+ tabIndex={active ? 0 : -1}
293
  onClick={onClick}
294
  className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all cursor-pointer ${
295
  active
 
305
  function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
306
  return (
307
  <button
308
+ role="tab"
309
+ aria-selected={active}
310
+ tabIndex={active ? 0 : -1}
311
  onClick={onClick}
312
  className={`px-3 py-1.5 rounded-[var(--radius-lg)] text-xs font-semibold transition-all cursor-pointer ${
313
  active
 
323
  function VisChipButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
324
  return (
325
  <button
326
+ role="radio"
327
+ aria-checked={active}
328
+ tabIndex={active ? 0 : -1}
329
  onClick={onClick}
330
  className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
331
  active
 
341
  function ChipButton({ active, onClick, children, disabled }: { active: boolean; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {
342
  return (
343
  <button
344
+ role="radio"
345
+ aria-checked={active}
346
+ tabIndex={active ? 0 : -1}
347
  onClick={onClick}
348
  disabled={disabled}
349
  className={`px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-all ${
apps/web/src/components/bank/QuestionCard.tsx CHANGED
@@ -1,9 +1,10 @@
1
  import { Button } from "@labas/ui/components/button";
2
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
  import { formatLabel } from "@/lib/format";
 
4
 
5
  interface QuestionCardProps {
6
- q: any;
7
  isFeatured?: boolean;
8
  isInBundle: boolean;
9
  disabled?: boolean;
@@ -99,7 +100,7 @@ export function QuestionCard({
99
  <div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
100
  <div className="flex gap-2">
101
  <span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
102
- {formatLabel(q.format)}
103
  </span>
104
  <span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
105
  Lv.{q.difficulty}
@@ -128,6 +129,7 @@ export function QuestionCard({
128
  </button>
129
  <button
130
  onClick={onDelete}
 
131
  className="text-[var(--pomegranate-400)] hover:bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full transition-colors cursor-pointer"
132
  >
133
  <MaterialIcon name="delete" className="text-sm" />
 
1
  import { Button } from "@labas/ui/components/button";
2
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
  import { formatLabel } from "@/lib/format";
4
+ import type { Question } from "@/lib/types";
5
 
6
  interface QuestionCardProps {
7
+ q: Question;
8
  isFeatured?: boolean;
9
  isInBundle: boolean;
10
  disabled?: boolean;
 
100
  <div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
101
  <div className="flex gap-2">
102
  <span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
103
+ {formatLabel(q.format ?? "")}
104
  </span>
105
  <span className="px-2 py-0.5 rounded bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-medium">
106
  Lv.{q.difficulty}
 
129
  </button>
130
  <button
131
  onClick={onDelete}
132
+ aria-label="Hapus soal"
133
  className="text-[var(--pomegranate-400)] hover:bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full transition-colors cursor-pointer"
134
  >
135
  <MaterialIcon name="delete" className="text-sm" />
apps/web/src/components/bank/QuestionDetailModal.tsx CHANGED
@@ -1,10 +1,17 @@
1
  import { useQuery, useMutation } from "@tanstack/react-query";
2
  import { Button } from "@labas/ui/components/button";
3
  import { Card, CardContent } from "@labas/ui/components/card";
 
 
 
 
 
 
4
  import { authClient } from "@/lib/auth-client";
5
  import { trpc } from "@/utils/trpc";
6
  import { formatLabel } from "@/lib/format";
7
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
8
 
9
  export function QuestionDetailModal({
10
  question,
@@ -13,7 +20,7 @@ export function QuestionDetailModal({
13
  onToggleSelect,
14
  isSelectable,
15
  }: {
16
- question: any;
17
  onClose: () => void;
18
  isSelected: boolean;
19
  onToggleSelect: () => void;
@@ -32,36 +39,26 @@ export function QuestionDetailModal({
32
  });
33
 
34
  return (
35
- <div
36
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
37
- onClick={(e) => {
38
- if (e.target === e.currentTarget) onClose();
39
- }}
40
- >
41
- <div className="bg-[var(--warm-cream)] w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
42
- {/* Header */}
43
- <div className="flex items-start justify-between mb-6">
44
- <div className="flex gap-2 flex-wrap">
45
- <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
46
- {question.examTypeName}
47
- </span>
48
- <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
49
- {question.sectionTypeName}
50
- </span>
51
- <span className="px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] text-sm font-semibold">
52
- {formatLabel(question.format)}
53
- </span>
54
- <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
55
- Level {question.difficulty}
56
- </span>
57
  </div>
58
- <button
59
- onClick={onClose}
60
- className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
61
- >
62
- <MaterialIcon name="close" className="text-[var(--clay-black)]" />
63
- </button>
64
- </div>
65
 
66
  {/* Passage */}
67
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
@@ -112,7 +109,7 @@ export function QuestionDetailModal({
112
  <span className="font-semibold text-[var(--clay-black)]">
113
  Jenis soal:{" "}
114
  </span>
115
- {formatLabel(question.format)}
116
  </div>
117
  )}
118
  </CardContent>
@@ -139,6 +136,7 @@ export function QuestionDetailModal({
139
  <button
140
  key={star}
141
  onClick={() => rateMutation.mutate({ questionId: question.id, score: star })}
 
142
  className="transition-transform hover:scale-110"
143
  >
144
  <MaterialIcon
@@ -208,7 +206,7 @@ export function QuestionDetailModal({
208
  Tutup
209
  </Button>
210
  </div>
211
- </div>
212
- </div>
213
  );
214
  }
 
1
  import { useQuery, useMutation } from "@tanstack/react-query";
2
  import { Button } from "@labas/ui/components/button";
3
  import { Card, CardContent } from "@labas/ui/components/card";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@labas/ui/components/dialog";
10
  import { authClient } from "@/lib/auth-client";
11
  import { trpc } from "@/utils/trpc";
12
  import { formatLabel } from "@/lib/format";
13
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
14
+ import type { Question } from "@/lib/types";
15
 
16
  export function QuestionDetailModal({
17
  question,
 
20
  onToggleSelect,
21
  isSelectable,
22
  }: {
23
+ question: Question;
24
  onClose: () => void;
25
  isSelected: boolean;
26
  onToggleSelect: () => void;
 
39
  });
40
 
41
  return (
42
+ <Dialog open onOpenChange={(v) => !v && onClose()}>
43
+ <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
44
+ <DialogHeader>
45
+ <div className="flex items-start justify-between">
46
+ <div className="flex gap-2 flex-wrap">
47
+ <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
48
+ {question.examTypeName}
49
+ </span>
50
+ <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
51
+ {question.sectionTypeName}
52
+ </span>
53
+ <span className="px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] text-sm font-semibold">
54
+ {formatLabel(question.format ?? "")}
55
+ </span>
56
+ <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
57
+ Level {question.difficulty}
58
+ </span>
59
+ </div>
 
 
 
 
60
  </div>
61
+ </DialogHeader>
 
 
 
 
 
 
62
 
63
  {/* Passage */}
64
  <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] mb-6">
 
109
  <span className="font-semibold text-[var(--clay-black)]">
110
  Jenis soal:{" "}
111
  </span>
112
+ {formatLabel(question.format ?? "")}
113
  </div>
114
  )}
115
  </CardContent>
 
136
  <button
137
  key={star}
138
  onClick={() => rateMutation.mutate({ questionId: question.id, score: star })}
139
+ aria-label={`Nilai ${star} bintang`}
140
  className="transition-transform hover:scale-110"
141
  >
142
  <MaterialIcon
 
206
  Tutup
207
  </Button>
208
  </div>
209
+ </DialogContent>
210
+ </Dialog>
211
  );
212
  }
apps/web/src/components/bank/SectionBrowser.tsx CHANGED
@@ -1,12 +1,13 @@
1
  import { Card, CardContent } from "@labas/ui/components/card";
2
  import { Button } from "@labas/ui/components/button";
3
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
4
 
5
  interface SectionBrowserProps {
6
  isLoading: boolean;
7
- groupedSections: Record<string, any[]>;
8
  isSectionInBundle: (id: string) => boolean;
9
- onToggleSection: (s: any) => void;
10
  }
11
 
12
  export function SectionBrowser({
@@ -39,11 +40,11 @@ export function SectionBrowser({
39
  {Object.entries(groupedSections).map(([groupKey, groupSections]) => (
40
  <Card key={groupKey} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
41
  <CardContent className="p-5">
42
- <h3 className="font-headline font-bold text-[var(--clay-black)] mb-4 text-sm uppercase tracking-wider">
43
  {groupKey}
44
- </h3>
45
  <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
46
- {groupSections.map((s: any) => {
47
  const inBundle = isSectionInBundle(s.id);
48
  return (
49
  <div
 
1
  import { Card, CardContent } from "@labas/ui/components/card";
2
  import { Button } from "@labas/ui/components/button";
3
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
4
+ import type { PackageSection } from "@/lib/types";
5
 
6
  interface SectionBrowserProps {
7
  isLoading: boolean;
8
+ groupedSections: Record<string, PackageSection[]>;
9
  isSectionInBundle: (id: string) => boolean;
10
+ onToggleSection: (s: PackageSection) => void;
11
  }
12
 
13
  export function SectionBrowser({
 
40
  {Object.entries(groupedSections).map(([groupKey, groupSections]) => (
41
  <Card key={groupKey} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
42
  <CardContent className="p-5">
43
+ <h2 className="font-headline font-bold text-[var(--clay-black)] mb-4 text-sm uppercase tracking-wider">
44
  {groupKey}
45
+ </h2>
46
  <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
47
+ {groupSections.map((s) => {
48
  const inBundle = isSectionInBundle(s.id);
49
  return (
50
  <div
apps/web/src/components/bank/SoalBrowser.tsx CHANGED
@@ -4,10 +4,11 @@ import { Button } from "@labas/ui/components/button";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
  import { QuestionCard } from "./QuestionCard";
6
  import { CalloutCard } from "./CalloutCard";
 
7
 
8
  interface SoalBrowserProps {
9
  isLoading: boolean;
10
- questions: any[];
11
  hasMore: boolean;
12
  isFetchingNextPage: boolean;
13
  hasFilters: boolean;
@@ -16,8 +17,8 @@ interface SoalBrowserProps {
16
  tab: "mine" | "public";
17
  filterKey: string;
18
  isQuestionInBundle: (id: string) => boolean;
19
- onToggleQuestion: (q: any) => void;
20
- onOpenDetail: (q: any) => void;
21
  onTogglePublic: (id: string) => void;
22
  onDelete: (id: string) => void;
23
  onLoadMore: () => void;
@@ -47,7 +48,7 @@ export function SoalBrowser({
47
  onPublishAllPrivate,
48
  }: SoalBrowserProps) {
49
  const sentinelRef = useRef<HTMLDivElement>(null);
50
- const isLocked = (q: any) => !!lockedExamType && q.examTypeId !== lockedExamType;
51
 
52
  // ── Bulk mode ──
53
  const [bulkMode, setBulkMode] = useState(false);
@@ -64,7 +65,7 @@ export function SoalBrowser({
64
  : false;
65
 
66
  const privateQuestions = questions.filter(
67
- (q: any) => !q.isPublic && q.creatorUserId === userId,
68
  );
69
 
70
  const handleDismissCallout = () => {
@@ -75,7 +76,7 @@ export function SoalBrowser({
75
 
76
  const handlePublishAllPrivate = () => {
77
  if (onPublishAllPrivate && privateQuestions.length > 0) {
78
- onPublishAllPrivate(privateQuestions.map((q: any) => q.id));
79
  }
80
  };
81
 
@@ -104,7 +105,7 @@ export function SoalBrowser({
104
  const clearSelection = () => setSelectedIds(new Set());
105
 
106
  const selectAll = () => {
107
- setSelectedIds(new Set(questions.map((q: any) => q.id)));
108
  };
109
 
110
  const handleBulkPublish = () => {
 
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
  import { QuestionCard } from "./QuestionCard";
6
  import { CalloutCard } from "./CalloutCard";
7
+ import type { Question } from "@/lib/types";
8
 
9
  interface SoalBrowserProps {
10
  isLoading: boolean;
11
+ questions: Question[];
12
  hasMore: boolean;
13
  isFetchingNextPage: boolean;
14
  hasFilters: boolean;
 
17
  tab: "mine" | "public";
18
  filterKey: string;
19
  isQuestionInBundle: (id: string) => boolean;
20
+ onToggleQuestion: (q: Question) => void;
21
+ onOpenDetail: (q: Question) => void;
22
  onTogglePublic: (id: string) => void;
23
  onDelete: (id: string) => void;
24
  onLoadMore: () => void;
 
48
  onPublishAllPrivate,
49
  }: SoalBrowserProps) {
50
  const sentinelRef = useRef<HTMLDivElement>(null);
51
+ const isLocked = (q: Question) => !!lockedExamType && q.examTypeId !== lockedExamType;
52
 
53
  // ── Bulk mode ──
54
  const [bulkMode, setBulkMode] = useState(false);
 
65
  : false;
66
 
67
  const privateQuestions = questions.filter(
68
+ (q) => !q.isPublic && q.creatorUserId === userId,
69
  );
70
 
71
  const handleDismissCallout = () => {
 
76
 
77
  const handlePublishAllPrivate = () => {
78
  if (onPublishAllPrivate && privateQuestions.length > 0) {
79
+ onPublishAllPrivate(privateQuestions.map((q) => q.id));
80
  }
81
  };
82
 
 
105
  const clearSelection = () => setSelectedIds(new Set());
106
 
107
  const selectAll = () => {
108
+ setSelectedIds(new Set(questions.map((q) => q.id)));
109
  };
110
 
111
  const handleBulkPublish = () => {
apps/web/src/components/generate/ResultSection.tsx CHANGED
@@ -15,7 +15,7 @@ export function ResultSection({ result, generatedPackageId, onClear }: ResultSec
15
  <div className="mt-16 space-y-6">
16
  <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
17
  <div>
18
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h2>
19
  <p className="text-sm text-[var(--warm-charcoal)] mt-1">
20
  Paket latihan berhasil dibuat! Soal juga tersimpan di Bank Soal (privat). Jawaban & penjelasan disembunyikan agar latihan tetap fair.
21
  </p>
 
15
  <div className="mt-16 space-y-6">
16
  <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
17
  <div>
18
+ <h3 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Hasil Generate</h3>
19
  <p className="text-sm text-[var(--warm-charcoal)] mt-1">
20
  Paket latihan berhasil dibuat! Soal juga tersimpan di Bank Soal (privat). Jawaban & penjelasan disembunyikan agar latihan tetap fair.
21
  </p>
apps/web/src/components/package/EditPackageModal.tsx CHANGED
@@ -1,7 +1,12 @@
1
  import { useState } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
  import { Button } from "@labas/ui/components/button";
4
- import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
 
 
 
 
5
 
6
  export function EditPackageModal({
7
  initialTitle,
@@ -27,24 +32,13 @@ export function EditPackageModal({
27
  const [isPublic, setIsPublic] = useState(initialIsPublic);
28
 
29
  return (
30
- <div
31
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
32
- onClick={(e) => {
33
- if (e.target === e.currentTarget) onClose();
34
- }}
35
- >
36
- <div className="bg-[var(--warm-cream)] w-full max-w-lg rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow p-6 md:p-8">
37
- <div className="flex items-center justify-between mb-6">
38
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
39
  Edit Paket
40
- </h2>
41
- <button
42
- onClick={onClose}
43
- className="w-10 h-10 rounded-full bg-[var(--oat-light)] hover:bg-[var(--oat-border)] flex items-center justify-center transition-colors"
44
- >
45
- <MaterialIcon name="close" className="text-[var(--clay-black)]" />
46
- </button>
47
- </div>
48
 
49
  <div className="space-y-4">
50
  <div>
@@ -100,7 +94,7 @@ export function EditPackageModal({
100
  {isPending ? "Menyimpan..." : "Simpan"}
101
  </Button>
102
  </div>
103
- </div>
104
- </div>
105
  );
106
  }
 
1
  import { useState } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
  import { Button } from "@labas/ui/components/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from "@labas/ui/components/dialog";
10
 
11
  export function EditPackageModal({
12
  initialTitle,
 
32
  const [isPublic, setIsPublic] = useState(initialIsPublic);
33
 
34
  return (
35
+ <Dialog open onOpenChange={(v) => !v && onClose()}>
36
+ <DialogContent className="max-w-lg">
37
+ <DialogHeader>
38
+ <DialogTitle className="text-2xl font-headline font-bold text-[var(--clay-black)]">
 
 
 
 
 
39
  Edit Paket
40
+ </DialogTitle>
41
+ </DialogHeader>
 
 
 
 
 
 
42
 
43
  <div className="space-y-4">
44
  <div>
 
94
  {isPending ? "Menyimpan..." : "Simpan"}
95
  </Button>
96
  </div>
97
+ </DialogContent>
98
+ </Dialog>
99
  );
100
  }
apps/web/src/components/routes/BankPage.tsx CHANGED
@@ -17,6 +17,8 @@ import { SoalBrowser } from "@/components/bank/SoalBrowser";
17
  import { SectionBrowser } from "@/components/bank/SectionBrowser";
18
  import { BundleSidebar } from "@/components/bank/BundleSidebar";
19
  import { toast } from "sonner";
 
 
20
  import type { Step } from "react-joyride";
21
  import { createFileRoute, redirect } from "@tanstack/react-router";
22
  import z from "zod";
@@ -62,20 +64,20 @@ export function BankComponent() {
62
  const visibilityFilter = search.visibility ?? "all";
63
 
64
  // ── Infinite scroll state ──
65
- const [allQuestions, setAllQuestions] = useState<any[]>([]);
66
  const [offset, setOffset] = useState(0);
67
  const limit = 12;
68
  const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
69
 
70
  // ── Sidebar / Bundle State ──
71
- const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
72
- const [bundleSections, setBundleSections] = useState<any[]>([]);
73
  const [bundleTitle, setBundleTitle] = useState("");
74
  const [bundleDescription, setBundleDescription] = useState("");
75
  const [bundleIsPublic, setBundleIsPublic] = useState(false);
76
 
77
  // ── Modals ──
78
- const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
79
  const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
80
 
81
  // ── Filter UI State ──
@@ -118,8 +120,8 @@ export function BankComponent() {
118
  setAllQuestions(data.questions ?? []);
119
  } else {
120
  setAllQuestions((prev) => {
121
- const existingIds = new Set(prev.map((q: any) => q.id));
122
- const newQs = (data.questions ?? []).filter((q: any) => !existingIds.has(q.id));
123
  return [...prev, ...newQs];
124
  });
125
  }
@@ -158,23 +160,24 @@ export function BankComponent() {
158
  onSuccess: () => questionQuery.refetch(),
159
  });
160
 
161
- const bulkPublish = useMutation({
162
- ...trpc.question.bulkPublish.mutationOptions(),
163
- onSuccess: (data) => {
164
- questionQuery.refetch();
165
- if (data.skipped > 0) {
166
- toast.success(
167
- `${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
168
- { description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
169
- );
170
- } else {
171
- toast.success(`${data.updated} soal berhasil dipublikasikan`);
172
- }
173
- },
174
- onError: (err: any) => {
175
- toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: err.message });
176
- },
177
- });
 
178
 
179
  // ── Navigation helpers ──
180
  const setMode = (newMode: Mode) => {
@@ -258,7 +261,7 @@ export function BankComponent() {
258
  const isSectionInBundle = (sid: string) =>
259
  bundleSections.some((s) => s.id === sid);
260
 
261
- const toggleQuestion = (q: any) => {
262
  if (lockedExamType && q.examTypeId !== lockedExamType) {
263
  toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
264
  return;
@@ -270,7 +273,7 @@ export function BankComponent() {
270
  });
271
  };
272
 
273
- const toggleSection = (s: any) => {
274
  setBundleSections((prev) => {
275
  const exists = prev.find((x) => x.id === s.id);
276
  if (exists) return prev.filter((x) => x.id !== s.id);
@@ -314,8 +317,8 @@ export function BankComponent() {
314
  setBundleQuestions([]);
315
  setBundleTitle("");
316
  setBundleDescription("");
317
- } catch (err: any) {
318
- toast.error("Gagal membuat paket", { description: err.message });
319
  }
320
  };
321
 
@@ -327,7 +330,7 @@ export function BankComponent() {
327
  description: bundleDescription,
328
  isPublic: bundleIsPublic,
329
  sections: bundleSections.map((s, i) => ({
330
- sourcePackageId: s.packageId,
331
  sourceSectionId: s.id,
332
  orderIndex: i,
333
  })),
@@ -336,8 +339,8 @@ export function BankComponent() {
336
  setBundleTitle("");
337
  setBundleDescription("");
338
  toast.success("Combo paket berhasil dibuat!");
339
- } catch (err: any) {
340
- toast.error("Gagal membuat combo", { description: err.message });
341
  }
342
  };
343
 
@@ -388,12 +391,12 @@ export function BankComponent() {
388
  const questions = allQuestions;
389
 
390
  const sections = sectionQuery.data?.sections ?? [];
391
- const groupedSections = sections.reduce((groups: Record<string, any[]>, s: any) => {
392
  const key = `${s.examTypeName ?? "Unknown"} β€” ${s.packageTitle ?? "Untitled"}`;
393
  if (!groups[key]) groups[key] = [];
394
- groups[key].push(s);
395
  return groups;
396
- }, {} as Record<string, any[]>);
397
 
398
  const isCreating =
399
  createPackage.isPending ||
 
17
  import { SectionBrowser } from "@/components/bank/SectionBrowser";
18
  import { BundleSidebar } from "@/components/bank/BundleSidebar";
19
  import { toast } from "sonner";
20
+ import { getErrorMessage } from "@/lib/error-utils";
21
+ import type { Question, PackageSection } from "@/lib/types";
22
  import type { Step } from "react-joyride";
23
  import { createFileRoute, redirect } from "@tanstack/react-router";
24
  import z from "zod";
 
64
  const visibilityFilter = search.visibility ?? "all";
65
 
66
  // ── Infinite scroll state ──
67
+ const [allQuestions, setAllQuestions] = useState<Question[]>([]);
68
  const [offset, setOffset] = useState(0);
69
  const limit = 12;
70
  const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
71
 
72
  // ── Sidebar / Bundle State ──
73
+ const [bundleQuestions, setBundleQuestions] = useState<Question[]>([]);
74
+ const [bundleSections, setBundleSections] = useState<PackageSection[]>([]);
75
  const [bundleTitle, setBundleTitle] = useState("");
76
  const [bundleDescription, setBundleDescription] = useState("");
77
  const [bundleIsPublic, setBundleIsPublic] = useState(false);
78
 
79
  // ── Modals ──
80
+ const [selectedQuestion, setSelectedQuestion] = useState<Question | null>(null);
81
  const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
82
 
83
  // ── Filter UI State ──
 
120
  setAllQuestions(data.questions ?? []);
121
  } else {
122
  setAllQuestions((prev) => {
123
+ const existingIds = new Set(prev.map((q) => q.id));
124
+ const newQs = (data.questions ?? []).filter((q: Question) => !existingIds.has(q.id));
125
  return [...prev, ...newQs];
126
  });
127
  }
 
160
  onSuccess: () => questionQuery.refetch(),
161
  });
162
 
163
+ const bulkPublish = useMutation(
164
+ trpc.question.bulkPublish.mutationOptions({
165
+ onSuccess: (data) => {
166
+ questionQuery.refetch();
167
+ if (data.skipped > 0) {
168
+ toast.success(
169
+ `${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
170
+ { description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
171
+ );
172
+ } else {
173
+ toast.success(`${data.updated} soal berhasil dipublikasikan`);
174
+ }
175
+ },
176
+ onError: (err) => {
177
+ toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: getErrorMessage(err) });
178
+ },
179
+ }),
180
+ );
181
 
182
  // ── Navigation helpers ──
183
  const setMode = (newMode: Mode) => {
 
261
  const isSectionInBundle = (sid: string) =>
262
  bundleSections.some((s) => s.id === sid);
263
 
264
+ const toggleQuestion = (q: Question) => {
265
  if (lockedExamType && q.examTypeId !== lockedExamType) {
266
  toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
267
  return;
 
273
  });
274
  };
275
 
276
+ const toggleSection = (s: PackageSection) => {
277
  setBundleSections((prev) => {
278
  const exists = prev.find((x) => x.id === s.id);
279
  if (exists) return prev.filter((x) => x.id !== s.id);
 
317
  setBundleQuestions([]);
318
  setBundleTitle("");
319
  setBundleDescription("");
320
+ } catch (err: unknown) {
321
+ toast.error("Gagal membuat paket", { description: getErrorMessage(err) });
322
  }
323
  };
324
 
 
330
  description: bundleDescription,
331
  isPublic: bundleIsPublic,
332
  sections: bundleSections.map((s, i) => ({
333
+ sourcePackageId: s.packageId ?? "",
334
  sourceSectionId: s.id,
335
  orderIndex: i,
336
  })),
 
339
  setBundleTitle("");
340
  setBundleDescription("");
341
  toast.success("Combo paket berhasil dibuat!");
342
+ } catch (err: unknown) {
343
+ toast.error("Gagal membuat combo", { description: getErrorMessage(err) });
344
  }
345
  };
346
 
 
391
  const questions = allQuestions;
392
 
393
  const sections = sectionQuery.data?.sections ?? [];
394
+ const groupedSections = sections.reduce<Record<string, PackageSection[]>>((groups, s) => {
395
  const key = `${s.examTypeName ?? "Unknown"} β€” ${s.packageTitle ?? "Untitled"}`;
396
  if (!groups[key]) groups[key] = [];
397
+ groups[key].push(s as PackageSection);
398
  return groups;
399
+ }, {});
400
 
401
  const isCreating =
402
  createPackage.isPending ||
apps/web/src/components/routes/GeneratePage.tsx CHANGED
@@ -181,17 +181,18 @@ export function RouteComponent() {
181
  maxTokens: selectedConfig!.maxTokens ?? 16384,
182
  };
183
 
 
184
  generate.mutate({
185
- examType: examType as any,
186
- section: selectedSections[0] as any,
187
- selectedSections: selectedSections as any,
188
- formats: selectedFormats as any,
189
  difficulty: difficulty + 1,
190
  topics: selectedTopics,
191
  questionCount,
192
  mode,
193
  apiKeyConfig,
194
- } as any);
195
  };
196
 
197
  const sectionSplits = (() => {
@@ -656,13 +657,37 @@ export function RouteComponent() {
656
  </div>
657
 
658
  {/* Tab Bar */}
659
- <div className="flex gap-1 mb-6 border-b border-[var(--oat-border)] overflow-x-auto">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  {completedResults.map((res, idx) => {
661
  const questions = res.result?.questions ?? [];
662
  const isActive = idx === activeTabIdx;
663
  return (
664
  <button
665
  key={res.jobId}
 
 
 
 
666
  onClick={() => setActiveTabIdx(idx)}
667
  className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
668
  isActive
@@ -680,15 +705,18 @@ export function RouteComponent() {
680
  <span className="text-xs text-[var(--warm-charcoal)]">
681
  {questions.length} soal
682
  </span>
683
- <button
684
  onClick={(e) => {
685
  e.stopPropagation();
686
  removeJob(res.jobId);
687
  }}
688
- className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/10 text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors"
 
 
 
689
  >
690
  <MaterialIcon name="close" className="text-xs" />
691
- </button>
692
  </button>
693
  );
694
  })}
@@ -696,11 +724,17 @@ export function RouteComponent() {
696
 
697
  {/* Active Tab Content */}
698
  {activeResult && (
699
- <ResultSection
700
- result={activeResult.result}
701
- generatedPackageId={activeResult.generatedPackageId}
702
- onClear={resetAll}
703
- />
 
 
 
 
 
 
704
  )}
705
  </div>
706
  )}
 
181
  maxTokens: selectedConfig!.maxTokens ?? 16384,
182
  };
183
 
184
+ type GenerateMutateInput = Parameters<typeof generate.mutate>[0];
185
  generate.mutate({
186
+ examType,
187
+ section: selectedSections[0],
188
+ selectedSections,
189
+ formats: selectedFormats,
190
  difficulty: difficulty + 1,
191
  topics: selectedTopics,
192
  questionCount,
193
  mode,
194
  apiKeyConfig,
195
+ } as GenerateMutateInput);
196
  };
197
 
198
  const sectionSplits = (() => {
 
657
  </div>
658
 
659
  {/* Tab Bar */}
660
+ <div
661
+ className="flex gap-1 mb-6 border-b border-[var(--oat-border)] overflow-x-auto"
662
+ role="tablist"
663
+ aria-label="Hasil generate"
664
+ onKeyDown={(e) => {
665
+ if (completedResults.length === 0) return;
666
+ if (e.key === "ArrowRight") {
667
+ e.preventDefault();
668
+ setActiveTabIdx((i) => Math.min(completedResults.length - 1, i + 1));
669
+ } else if (e.key === "ArrowLeft") {
670
+ e.preventDefault();
671
+ setActiveTabIdx((i) => Math.max(0, i - 1));
672
+ } else if (e.key === "Home") {
673
+ e.preventDefault();
674
+ setActiveTabIdx(0);
675
+ } else if (e.key === "End") {
676
+ e.preventDefault();
677
+ setActiveTabIdx(completedResults.length - 1);
678
+ }
679
+ }}
680
+ >
681
  {completedResults.map((res, idx) => {
682
  const questions = res.result?.questions ?? [];
683
  const isActive = idx === activeTabIdx;
684
  return (
685
  <button
686
  key={res.jobId}
687
+ role="tab"
688
+ aria-selected={isActive}
689
+ aria-controls={`generate-panel-${idx}`}
690
+ tabIndex={isActive ? 0 : -1}
691
  onClick={() => setActiveTabIdx(idx)}
692
  className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
693
  isActive
 
705
  <span className="text-xs text-[var(--warm-charcoal)]">
706
  {questions.length} soal
707
  </span>
708
+ <span
709
  onClick={(e) => {
710
  e.stopPropagation();
711
  removeJob(res.jobId);
712
  }}
713
+ role="button"
714
+ aria-label={`Tutup tab ${res.mode}`}
715
+ tabIndex={-1}
716
+ className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/10 text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors cursor-pointer"
717
  >
718
  <MaterialIcon name="close" className="text-xs" />
719
+ </span>
720
  </button>
721
  );
722
  })}
 
724
 
725
  {/* Active Tab Content */}
726
  {activeResult && (
727
+ <div
728
+ role="tabpanel"
729
+ id={`generate-panel-${activeTabIdx}`}
730
+ aria-labelledby={`generate-tab-${activeTabIdx}`}
731
+ >
732
+ <ResultSection
733
+ result={activeResult.result}
734
+ generatedPackageId={activeResult.generatedPackageId}
735
+ onClear={resetAll}
736
+ />
737
+ </div>
738
  )}
739
  </div>
740
  )}
apps/web/src/components/routes/PackagesPage.tsx CHANGED
@@ -7,6 +7,11 @@ import { trpc } from "@/utils/trpc";
7
  import { Input } from "@labas/ui/components/input";
8
  import { Button } from "@labas/ui/components/button";
9
  import { Card, CardContent } from "@labas/ui/components/card";
 
 
 
 
 
10
  import {
11
  Select,
12
  SelectContent,
@@ -209,28 +214,22 @@ export function PackagesComponent() {
209
  <GettingStartedCard />
210
 
211
  {/* Tabs */}
212
- <div className="flex gap-2 mb-6">
213
- <button
214
- onClick={() => setTab("all")}
215
- className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
216
- tab === "all"
217
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
218
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
219
- }`}
220
- >
221
- Semua Paket
222
- </button>
223
- <button
224
- onClick={() => setTab("mine")}
225
- className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
226
- tab === "mine"
227
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
228
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
229
- }`}
230
- >
231
- Paket Saya
232
- </button>
233
- </div>
234
 
235
  {/* Filters */}
236
  <div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
@@ -240,6 +239,7 @@ export function PackagesComponent() {
240
  placeholder="Cari paket..."
241
  value={searchText}
242
  onChange={(e) => setSearch(e.target.value)}
 
243
  className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
244
  />
245
  </div>
 
7
  import { Input } from "@labas/ui/components/input";
8
  import { Button } from "@labas/ui/components/button";
9
  import { Card, CardContent } from "@labas/ui/components/card";
10
+ import {
11
+ Tabs,
12
+ TabsList,
13
+ TabsTrigger,
14
+ } from "@labas/ui/components/tabs";
15
  import {
16
  Select,
17
  SelectContent,
 
214
  <GettingStartedCard />
215
 
216
  {/* Tabs */}
217
+ <Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mb-6">
218
+ <TabsList variant="line" className="h-11 bg-transparent">
219
+ <TabsTrigger
220
+ value="all"
221
+ className="px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold data-[active=true]:bg-[var(--clay-black)] data-[active=true]:text-[var(--pure-white)] data-[active=true]:clay-shadow bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)] transition-all"
222
+ >
223
+ Semua Paket
224
+ </TabsTrigger>
225
+ <TabsTrigger
226
+ value="mine"
227
+ className="px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold data-[active=true]:bg-[var(--clay-black)] data-[active=true]:text-[var(--pure-white)] data-[active=true]:clay-shadow bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)] transition-all"
228
+ >
229
+ Paket Saya
230
+ </TabsTrigger>
231
+ </TabsList>
232
+ </Tabs>
 
 
 
 
 
 
233
 
234
  {/* Filters */}
235
  <div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
 
239
  placeholder="Cari paket..."
240
  value={searchText}
241
  onChange={(e) => setSearch(e.target.value)}
242
+ aria-label="Cari paket"
243
  className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
244
  />
245
  </div>
apps/web/src/components/routes/SettingsPage.tsx CHANGED
@@ -90,9 +90,9 @@ function TokenUsageSection() {
90
  </div>
91
 
92
  <div>
93
- <h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
94
  Riwayat Generate Hari Ini
95
- </h3>
96
  {isLoading ? (
97
  <div className="space-y-2">
98
  {[1, 2, 3].map((i) => (
 
90
  </div>
91
 
92
  <div>
93
+ <h2 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
94
  Riwayat Generate Hari Ini
95
+ </h2>
96
  {isLoading ? (
97
  <div className="space-y-2">
98
  {[1, 2, 3].map((i) => (
apps/web/src/components/settings/AccountSettings.tsx CHANGED
@@ -4,6 +4,7 @@ import { Input } from "@labas/ui/components/input";
4
  import { Label } from "@labas/ui/components/label";
5
  import { Button } from "@labas/ui/components/button";
6
  import { toast } from "sonner";
 
7
  import { authClient } from "@/lib/auth-client";
8
 
9
  export function AccountSettings() {
@@ -39,8 +40,8 @@ export function AccountSettings() {
39
  setCurrentPassword("");
40
  setNewPassword("");
41
  setConfirmPassword("");
42
- } catch (err: any) {
43
- toast.error(err?.message || "Gagal mengubah password");
44
  } finally {
45
  setIsChangingPassword(false);
46
  }
@@ -54,8 +55,8 @@ export function AccountSettings() {
54
  await authClient.changeEmail({ newEmail });
55
  toast.success("Email berhasil diubah. Cek email baru Anda untuk verifikasi.");
56
  setNewEmail("");
57
- } catch (err: any) {
58
- toast.error(err?.message || "Gagal mengubah email");
59
  } finally {
60
  setIsChangingEmail(false);
61
  }
 
4
  import { Label } from "@labas/ui/components/label";
5
  import { Button } from "@labas/ui/components/button";
6
  import { toast } from "sonner";
7
+ import { getErrorMessage } from "@/lib/error-utils";
8
  import { authClient } from "@/lib/auth-client";
9
 
10
  export function AccountSettings() {
 
40
  setCurrentPassword("");
41
  setNewPassword("");
42
  setConfirmPassword("");
43
+ } catch (err: unknown) {
44
+ toast.error(getErrorMessage(err) || "Gagal mengubah password");
45
  } finally {
46
  setIsChangingPassword(false);
47
  }
 
55
  await authClient.changeEmail({ newEmail });
56
  toast.success("Email berhasil diubah. Cek email baru Anda untuk verifikasi.");
57
  setNewEmail("");
58
+ } catch (err: unknown) {
59
+ toast.error(getErrorMessage(err) || "Gagal mengubah email");
60
  } finally {
61
  setIsChangingEmail(false);
62
  }
apps/web/src/components/test/AccentKeyboard.tsx CHANGED
@@ -7,13 +7,14 @@ const ACCENT_CHARS: Record<string, string[]> = {
7
  };
8
 
9
  interface AccentKeyboardProps {
10
- examType: string;
11
  onInsert: (char: string) => void;
12
  disabled?: boolean;
13
  }
14
 
15
  export function AccentKeyboard({ examType, onInsert, disabled }: AccentKeyboardProps) {
16
- const chars = ACCENT_CHARS[examType];
 
17
  if (!chars || chars.length === 0) return null;
18
 
19
  return (
@@ -21,7 +22,7 @@ export function AccentKeyboard({ examType, onInsert, disabled }: AccentKeyboardP
21
  <span className="w-full text-[10px] font-medium text-[var(--warm-charcoal)] uppercase tracking-wider mb-0.5">
22
  Special Characters
23
  </span>
24
- {chars.map((char) => (
25
  <button
26
  key={char}
27
  type="button"
 
7
  };
8
 
9
  interface AccentKeyboardProps {
10
+ examType?: string;
11
  onInsert: (char: string) => void;
12
  disabled?: boolean;
13
  }
14
 
15
  export function AccentKeyboard({ examType, onInsert, disabled }: AccentKeyboardProps) {
16
+ if (!examType) return null;
17
+ const chars: string[] | undefined = ACCENT_CHARS[examType];
18
  if (!chars || chars.length === 0) return null;
19
 
20
  return (
 
22
  <span className="w-full text-[10px] font-medium text-[var(--warm-charcoal)] uppercase tracking-wider mb-0.5">
23
  Special Characters
24
  </span>
25
+ {chars.map((char: string) => (
26
  <button
27
  key={char}
28
  type="button"
apps/web/src/components/test/AttemptTestView.tsx CHANGED
@@ -7,6 +7,7 @@ import { trpc } from "@/utils/trpc";
7
  import { QuestionInput } from "./QuestionInput";
8
  import { AccentKeyboard } from "./AccentKeyboard";
9
  import { parseFurigana } from "@/lib/furigana";
 
10
  import { AttemptHeader } from "./attempt/AttemptHeader";
11
  import { FloatingNav } from "./attempt/FloatingNav";
12
  import { FinishDialog } from "./attempt/FinishDialog";
@@ -14,7 +15,7 @@ import { AbandonDialog } from "./attempt/AbandonDialog";
14
 
15
  interface AttemptTestViewProps {
16
  attemptId: string;
17
- pkg: any;
18
  currentSectionIdx: number;
19
  setCurrentSectionIdx: (idx: number) => void;
20
  answers: Record<string, string>;
@@ -32,7 +33,7 @@ interface AttemptTestViewProps {
32
  }
33
 
34
  interface QuestionCardProps {
35
- q: any;
36
  globalIdx: number;
37
  answerValue: string;
38
  sectionResultId: string | undefined;
@@ -83,7 +84,7 @@ const QuestionCard = memo(function QuestionCard({
83
  {globalIdx}
84
  </span>
85
  <h2 className="text-xl font-bold text-[var(--clay-black)] capitalize">
86
- {q.format.replace(/_/g, " ")}
87
  </h2>
88
  </div>
89
  <button
@@ -101,7 +102,7 @@ const QuestionCard = memo(function QuestionCard({
101
  </div>
102
 
103
  <p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed" dir={q._isRtl ? "rtl" : undefined}>
104
- {q._useFurigana ? parseFurigana(q.questionText) : q.questionText}
105
  </p>
106
 
107
  <div className="pl-0">
@@ -165,7 +166,7 @@ export function AttemptTestView({
165
  const currentSection = pkg.sections[currentSectionIdx];
166
  const sectionData = attempt?.sections?.[currentSectionIdx];
167
  const sectionResultId = sectionData?.sectionResultId;
168
- const examType: string = pkg.examType ?? "";
169
  const isRtl = examType === "TOAFL";
170
  const useFurigana = examType === "JLPT" || examType === "TOPIK";
171
 
@@ -176,7 +177,7 @@ export function AttemptTestView({
176
  const key = `${currentSectionIdx}:${sectionResultId}`;
177
  if (flushKeyRef.current === key) return;
178
  flushKeyRef.current = key;
179
- for (const q of currentSection.questions as any[]) {
180
  const v = answers[q.id];
181
  if (v) void onAnswerChange(q.id, sectionResultId, v);
182
  }
@@ -193,9 +194,9 @@ export function AttemptTestView({
193
 
194
  // Build global question index across all sections
195
  const allQuestions: Array<{ id: string; sectionIdx: number; localIdx: number; passageText?: string }> = [];
196
- pkg.sections.forEach((sec: any, sIdx: number) => {
197
- sec.questions.forEach((q: any, qIdx: number) => {
198
- allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText });
199
  });
200
  });
201
 
@@ -207,7 +208,7 @@ export function AttemptTestView({
207
  }
208
  }, [allQuestions]);
209
 
210
- const addQuestionMeta = (q: any) => ({
211
  ...q,
212
  _examType: examType,
213
  _isRtl: isRtl,
@@ -215,7 +216,7 @@ export function AttemptTestView({
215
  });
216
 
217
  const activeQuestion = currentSection?.questions?.find(
218
- (q: any) => q.id === activeQuestionId,
219
  );
220
  const activeQuestionWithMeta = activeQuestion ? addQuestionMeta(activeQuestion) : null;
221
  const passageToShow =
@@ -279,8 +280,8 @@ export function AttemptTestView({
279
 
280
  <div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
281
  <AttemptHeader
282
- pkgTitle={pkg.title}
283
- currentSectionTitle={currentSection.title}
284
  timeElapsed={timeElapsed}
285
  answeredCount={answeredCount}
286
  totalQuestions={totalQuestions}
 
7
  import { QuestionInput } from "./QuestionInput";
8
  import { AccentKeyboard } from "./AccentKeyboard";
9
  import { parseFurigana } from "@/lib/furigana";
10
+ import type { Question, Package, PackageSection } from "@/lib/types";
11
  import { AttemptHeader } from "./attempt/AttemptHeader";
12
  import { FloatingNav } from "./attempt/FloatingNav";
13
  import { FinishDialog } from "./attempt/FinishDialog";
 
15
 
16
  interface AttemptTestViewProps {
17
  attemptId: string;
18
+ pkg: Package & { sections: PackageSection[] };
19
  currentSectionIdx: number;
20
  setCurrentSectionIdx: (idx: number) => void;
21
  answers: Record<string, string>;
 
33
  }
34
 
35
  interface QuestionCardProps {
36
+ q: Question & { _examType?: string; _isRtl?: boolean; _useFurigana?: boolean };
37
  globalIdx: number;
38
  answerValue: string;
39
  sectionResultId: string | undefined;
 
84
  {globalIdx}
85
  </span>
86
  <h2 className="text-xl font-bold text-[var(--clay-black)] capitalize">
87
+ {(q.format ?? "").replace(/_/g, " ")}
88
  </h2>
89
  </div>
90
  <button
 
102
  </div>
103
 
104
  <p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed" dir={q._isRtl ? "rtl" : undefined}>
105
+ {q._useFurigana ? parseFurigana(q.questionText ?? "") : (q.questionText ?? "")}
106
  </p>
107
 
108
  <div className="pl-0">
 
166
  const currentSection = pkg.sections[currentSectionIdx];
167
  const sectionData = attempt?.sections?.[currentSectionIdx];
168
  const sectionResultId = sectionData?.sectionResultId;
169
+ const examType: string = pkg.examTypeName ?? "";
170
  const isRtl = examType === "TOAFL";
171
  const useFurigana = examType === "JLPT" || examType === "TOPIK";
172
 
 
177
  const key = `${currentSectionIdx}:${sectionResultId}`;
178
  if (flushKeyRef.current === key) return;
179
  flushKeyRef.current = key;
180
+ for (const q of currentSection.questions ?? []) {
181
  const v = answers[q.id];
182
  if (v) void onAnswerChange(q.id, sectionResultId, v);
183
  }
 
194
 
195
  // Build global question index across all sections
196
  const allQuestions: Array<{ id: string; sectionIdx: number; localIdx: number; passageText?: string }> = [];
197
+ pkg.sections.forEach((sec: PackageSection, sIdx: number) => {
198
+ sec.questions?.forEach((q: Question, qIdx: number) => {
199
+ allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText ?? undefined });
200
  });
201
  });
202
 
 
208
  }
209
  }, [allQuestions]);
210
 
211
+ const addQuestionMeta = (q: Question) => ({
212
  ...q,
213
  _examType: examType,
214
  _isRtl: isRtl,
 
216
  });
217
 
218
  const activeQuestion = currentSection?.questions?.find(
219
+ (q: Question) => q.id === activeQuestionId,
220
  );
221
  const activeQuestionWithMeta = activeQuestion ? addQuestionMeta(activeQuestion) : null;
222
  const passageToShow =
 
280
 
281
  <div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
282
  <AttemptHeader
283
+ pkgTitle={pkg.title ?? ""}
284
+ currentSectionTitle={currentSection.title ?? ""}
285
  timeElapsed={timeElapsed}
286
  answeredCount={answeredCount}
287
  totalQuestions={totalQuestions}
apps/web/src/components/test/QuestionInput.tsx CHANGED
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
2
  import { Input } from "@labas/ui/components/input";
3
 
4
  import { MCQ_FORMATS } from "@/lib/question-formats";
 
5
 
6
  const TRUE_FALSE_CHOICES = [
7
  { key: "TRUE", label: "True" },
@@ -82,6 +83,7 @@ function DebouncedTextInput({
82
 
83
  return (
84
  <Input
 
85
  value={localValue}
86
  onChange={handleChange}
87
  onBlur={handleBlur}
@@ -98,7 +100,7 @@ export function QuestionInput({
98
  onChange,
99
  disabled,
100
  }: {
101
- question: any;
102
  value: string;
103
  onChange: (val: string) => void;
104
  disabled?: boolean;
@@ -147,6 +149,7 @@ export function QuestionInput({
147
  onChange={(e) => updateMapping(opt.key, e.target.value)}
148
  disabled={disabled}
149
  placeholder="Padanan..."
 
150
  className="w-24 px-3 py-2 text-sm rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] focus:outline-none focus:border-[var(--matcha-600)]"
151
  />
152
  </div>
@@ -156,7 +159,7 @@ export function QuestionInput({
156
  );
157
  }
158
 
159
- if (MCQ_FORMATS.includes(format)) {
160
  if (!options || options.length === 0) {
161
  return (
162
  <div className="text-sm text-[var(--warm-silver)] italic">
 
2
  import { Input } from "@labas/ui/components/input";
3
 
4
  import { MCQ_FORMATS } from "@/lib/question-formats";
5
+ import type { Question } from "@/lib/types";
6
 
7
  const TRUE_FALSE_CHOICES = [
8
  { key: "TRUE", label: "True" },
 
83
 
84
  return (
85
  <Input
86
+ aria-label="Jawaban esai"
87
  value={localValue}
88
  onChange={handleChange}
89
  onBlur={handleBlur}
 
100
  onChange,
101
  disabled,
102
  }: {
103
+ question: Question;
104
  value: string;
105
  onChange: (val: string) => void;
106
  disabled?: boolean;
 
149
  onChange={(e) => updateMapping(opt.key, e.target.value)}
150
  disabled={disabled}
151
  placeholder="Padanan..."
152
+ aria-label="Padanan jawaban"
153
  className="w-24 px-3 py-2 text-sm rounded-[var(--radius-md)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] focus:outline-none focus:border-[var(--matcha-600)]"
154
  />
155
  </div>
 
159
  );
160
  }
161
 
162
+ if ((MCQ_FORMATS as readonly string[]).includes(format ?? "")) {
163
  if (!options || options.length === 0) {
164
  return (
165
  <div className="text-sm text-[var(--warm-silver)] italic">
apps/web/src/components/test/attempt/AbandonDialog.tsx CHANGED
@@ -1,4 +1,11 @@
1
  import { Button } from "@labas/ui/components/button";
 
 
 
 
 
 
 
2
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
 
4
  interface AbandonDialogProps {
@@ -8,35 +15,23 @@ interface AbandonDialogProps {
8
  }
9
 
10
  export function AbandonDialog({ open, onClose, onConfirm }: AbandonDialogProps) {
11
- if (!open) return null;
12
-
13
  return (
14
- <div
15
- className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
16
- onClick={(e) => {
17
- if (e.target === e.currentTarget) onClose();
18
- }}
19
- role="dialog"
20
- aria-modal="true"
21
- aria-labelledby="abandon-dialog-title"
22
- >
23
- <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
24
- <div className="flex items-center gap-3 mb-4">
25
- <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
26
- <MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
27
  </div>
28
- <h2 id="abandon-dialog-title" className="text-xl font-headline font-bold text-[var(--clay-black)]">
29
- Keluar dari Latihan?
30
- </h2>
31
- </div>
32
-
33
- <div className="space-y-3 mb-6">
34
- <p className="text-sm text-[var(--warm-charcoal)]">
35
- Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
36
- </p>
37
- </div>
38
-
39
- <div className="flex gap-3">
40
  <Button
41
  variant="outline"
42
  onClick={onClose}
@@ -51,7 +46,7 @@ export function AbandonDialog({ open, onClose, onConfirm }: AbandonDialogProps)
51
  Keluar
52
  </Button>
53
  </div>
54
- </div>
55
- </div>
56
  );
57
  }
 
1
  import { Button } from "@labas/ui/components/button";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ DialogDescription,
8
+ } from "@labas/ui/components/dialog";
9
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
 
11
  interface AbandonDialogProps {
 
15
  }
16
 
17
  export function AbandonDialog({ open, onClose, onConfirm }: AbandonDialogProps) {
 
 
18
  return (
19
+ <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
20
+ <DialogContent className="max-w-md">
21
+ <DialogHeader>
22
+ <div className="flex items-center gap-3">
23
+ <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
24
+ <MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
25
+ </div>
26
+ <DialogTitle className="text-xl font-headline font-bold text-[var(--clay-black)]">
27
+ Keluar dari Latihan?
28
+ </DialogTitle>
 
 
 
29
  </div>
30
+ </DialogHeader>
31
+ <DialogDescription className="text-sm text-[var(--warm-charcoal)]">
32
+ Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
33
+ </DialogDescription>
34
+ <div className="flex gap-3 mt-2">
 
 
 
 
 
 
 
35
  <Button
36
  variant="outline"
37
  onClick={onClose}
 
46
  Keluar
47
  </Button>
48
  </div>
49
+ </DialogContent>
50
+ </Dialog>
51
  );
52
  }
apps/web/src/components/test/attempt/FinishDialog.tsx CHANGED
@@ -1,4 +1,11 @@
1
  import { Button } from "@labas/ui/components/button";
 
 
 
 
 
 
 
2
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
 
4
  interface FinishDialogProps {
@@ -10,44 +17,37 @@ interface FinishDialogProps {
10
  }
11
 
12
  export function FinishDialog({ open, onClose, onConfirm, answeredCount, totalQuestions }: FinishDialogProps) {
13
- if (!open) return null;
14
-
15
  return (
16
- <div
17
- className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
18
- onClick={(e) => {
19
- if (e.target === e.currentTarget) onClose();
20
- }}
21
- role="dialog"
22
- aria-modal="true"
23
- aria-labelledby="finish-dialog-title"
24
- >
25
- <div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
26
- <div className="flex items-center gap-3 mb-4">
27
- <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
28
- <MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
29
  </div>
30
- <h2 id="finish-dialog-title" className="text-xl font-headline font-bold text-[var(--clay-black)]">
31
- Selesaikan Latihan?
32
- </h2>
33
- </div>
34
-
35
- <div className="space-y-3 mb-6">
36
- <p className="text-sm text-[var(--warm-charcoal)]">
37
  Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
38
  </p>
39
  {answeredCount < totalQuestions && (
40
- <div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
41
  <MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
42
  <span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
43
  </div>
44
  )}
45
- <p className="text-sm text-[var(--warm-charcoal)]">
46
  Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
47
  </p>
48
- </div>
49
-
50
- <div className="flex gap-3">
51
  <Button
52
  variant="outline"
53
  onClick={onClose}
@@ -62,7 +62,7 @@ export function FinishDialog({ open, onClose, onConfirm, answeredCount, totalQue
62
  Selesaikan
63
  </Button>
64
  </div>
65
- </div>
66
- </div>
67
  );
68
  }
 
1
  import { Button } from "@labas/ui/components/button";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ DialogDescription,
8
+ } from "@labas/ui/components/dialog";
9
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
 
11
  interface FinishDialogProps {
 
17
  }
18
 
19
  export function FinishDialog({ open, onClose, onConfirm, answeredCount, totalQuestions }: FinishDialogProps) {
 
 
20
  return (
21
+ <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
22
+ <DialogContent className="max-w-md">
23
+ <DialogHeader>
24
+ <div className="flex items-center gap-3">
25
+ <div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
26
+ <MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
27
+ </div>
28
+ <DialogTitle className="text-xl font-headline font-bold text-[var(--clay-black)]">
29
+ Selesaikan Latihan?
30
+ </DialogTitle>
 
 
 
31
  </div>
32
+ </DialogHeader>
33
+ <DialogDescription
34
+ className="space-y-3 *:text-sm"
35
+ render={<div />}
36
+ >
37
+ <p className="text-[var(--warm-charcoal)]">
 
38
  Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
39
  </p>
40
  {answeredCount < totalQuestions && (
41
+ <div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-[var(--lemon-800)] flex items-start gap-2">
42
  <MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
43
  <span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
44
  </div>
45
  )}
46
+ <p className="text-[var(--warm-charcoal)]">
47
  Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
48
  </p>
49
+ </DialogDescription>
50
+ <div className="flex gap-3 mt-2">
 
51
  <Button
52
  variant="outline"
53
  onClick={onClose}
 
62
  Selesaikan
63
  </Button>
64
  </div>
65
+ </DialogContent>
66
+ </Dialog>
67
  );
68
  }
apps/web/src/components/test/attempt/FloatingNav.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { RefObject } from "react";
2
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
 
4
  interface FloatingNavProps {
@@ -23,6 +23,26 @@ export function FloatingNav({
23
  const isAnswered = (qId: string) => !!answers[qId];
24
  const isMarked = (qId: string) => markedQuestions.has(qId);
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  return (
27
  <nav
28
  className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]"
@@ -43,6 +63,7 @@ export function FloatingNav({
43
  className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
44
  role="tablist"
45
  aria-label="Daftar soal"
 
46
  >
47
  {questions.map((q, gIdx) => {
48
  const answered = isAnswered(q.id);
@@ -56,6 +77,8 @@ export function FloatingNav({
56
  onClick={() => onGoToQuestion(q.id)}
57
  role="tab"
58
  aria-selected={isActive}
 
 
59
  aria-label={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}${answered ? " (sudah dijawab)" : ""}`}
60
  className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
61
  answered
 
1
+ import type { RefObject, KeyboardEvent } from "react";
2
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
 
4
  interface FloatingNavProps {
 
23
  const isAnswered = (qId: string) => !!answers[qId];
24
  const isMarked = (qId: string) => markedQuestions.has(qId);
25
 
26
+ const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
27
+ if (questions.length === 0) return;
28
+ const activeIdx = questions.findIndex((q) => q.id === activeQuestionId);
29
+ if (e.key === "ArrowRight") {
30
+ e.preventDefault();
31
+ const next = Math.min(questions.length - 1, Math.max(0, activeIdx) + 1);
32
+ onGoToQuestion(questions[next].id);
33
+ } else if (e.key === "ArrowLeft") {
34
+ e.preventDefault();
35
+ const prev = Math.max(0, Math.max(0, activeIdx) - 1);
36
+ onGoToQuestion(questions[prev].id);
37
+ } else if (e.key === "Home") {
38
+ e.preventDefault();
39
+ onGoToQuestion(questions[0].id);
40
+ } else if (e.key === "End") {
41
+ e.preventDefault();
42
+ onGoToQuestion(questions[questions.length - 1].id);
43
+ }
44
+ };
45
+
46
  return (
47
  <nav
48
  className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]"
 
63
  className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
64
  role="tablist"
65
  aria-label="Daftar soal"
66
+ onKeyDown={handleKeyDown}
67
  >
68
  {questions.map((q, gIdx) => {
69
  const answered = isAnswered(q.id);
 
77
  onClick={() => onGoToQuestion(q.id)}
78
  role="tab"
79
  aria-selected={isActive}
80
+ tabIndex={isActive ? 0 : -1}
81
+ aria-controls={`question-panel-${q.id}`}
82
  aria-label={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}${answered ? " (sudah dijawab)" : ""}`}
83
  className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
84
  answered
apps/web/src/hooks/use-generation-jobs.ts CHANGED
@@ -1,277 +1,54 @@
1
- import { useState, useEffect, useCallback, useMemo, useRef } from "react";
2
- import { useQuery, useQueries } from "@tanstack/react-query";
3
  import { trpc, trpcClient } from "@/utils/trpc";
4
- import type { GenerationResult } from "@labas/ai";
5
  import { authClient } from "@/lib/auth-client";
 
 
 
 
6
 
7
- const STORAGE_KEY = "labas_active_jobs";
8
- const RESULTS_KEY = "labas_completed_results";
9
- const CLEARED_JOBS_KEY = "labas_cleared_jobs";
10
- const MAX_PARALLEL = 3;
11
-
12
- export interface ActiveJob {
13
- id: string;
14
- status: string;
15
- progress: number;
16
- progressMessage: string;
17
- mode: string;
18
- logs: unknown[];
19
- resultJson: unknown;
20
- errorMessage?: string;
21
- examTypeId?: string;
22
- sectionTypeId?: string;
23
- questionCount?: number;
24
- }
25
-
26
- export interface CompletedResult {
27
- jobId: string;
28
- result: GenerationResult;
29
- generatedPackageId: string | null;
30
- examType?: string;
31
- section?: string;
32
- mode: string;
33
- timestamp: number;
34
- }
35
-
36
- const ACTIVE_STATUSES = new Set([
37
- "pending",
38
- "running",
39
- "running_fast",
40
- "partial_ready",
41
- "running_quality",
42
- ]);
43
-
44
- const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
45
-
46
- function isTerminal(status: string): boolean {
47
- return TERMINAL_STATUSES.has(status);
48
- }
49
-
50
- function readStoredIds(): string[] {
51
- try {
52
- const raw = sessionStorage.getItem(STORAGE_KEY);
53
- return raw ? (JSON.parse(raw) as string[]) : [];
54
- } catch {
55
- return [];
56
- }
57
- }
58
-
59
- function writeStoredIds(ids: string[]) {
60
- if (ids.length === 0) {
61
- sessionStorage.removeItem(STORAGE_KEY);
62
- } else {
63
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
64
- }
65
- }
66
-
67
- function readStoredResults(): CompletedResult[] {
68
- try {
69
- const raw = sessionStorage.getItem(RESULTS_KEY);
70
- return raw ? (JSON.parse(raw) as CompletedResult[]) : [];
71
- } catch {
72
- return [];
73
- }
74
- }
75
-
76
- function writeStoredResults(results: CompletedResult[]) {
77
- if (results.length === 0) {
78
- sessionStorage.removeItem(RESULTS_KEY);
79
- } else {
80
- sessionStorage.setItem(RESULTS_KEY, JSON.stringify(results));
81
- }
82
- }
83
-
84
- function readClearedJobIds(): string[] {
85
- try {
86
- const raw = sessionStorage.getItem(CLEARED_JOBS_KEY);
87
- return raw ? (JSON.parse(raw) as string[]) : [];
88
- } catch {
89
- return [];
90
- }
91
- }
92
-
93
- function writeClearedJobIds(ids: string[]) {
94
- if (ids.length === 0) {
95
- sessionStorage.removeItem(CLEARED_JOBS_KEY);
96
- } else {
97
- sessionStorage.setItem(CLEARED_JOBS_KEY, JSON.stringify(ids));
98
- }
99
- }
100
-
101
- function broadcastJobs(ids: string[]) {
102
- window.dispatchEvent(
103
- new CustomEvent("labas:jobs-change", { detail: { ids } }),
104
- );
105
- }
106
 
107
  export function useGenerationJobs() {
108
  const { data: session } = authClient.useSession();
109
  const isAuthenticated = !!session;
110
 
111
- const [error, setError] = useState<string | null>(null);
112
- const [jobIds, setJobIdsState] = useState<string[]>([]);
113
- const [completedResults, setCompletedResults] = useState<CompletedResult[]>(
114
- [],
115
- );
116
-
117
- const processedJobStates = useRef<Record<string, string>>({});
118
- const removedJobIdsRef = useRef<Set<string>>(new Set());
119
-
120
- const setJobIds = useCallback(
121
- (updater: string[] | ((prev: string[]) => string[])) => {
122
- setJobIdsState((prev) => {
123
- const ids =
124
- typeof updater === "function" ? updater(prev) : updater;
125
- writeStoredIds(ids);
126
- broadcastJobs(ids);
127
- return ids;
128
- });
129
- },
130
- [],
131
- );
132
-
133
- const addJob = useCallback(
134
- (id: string) => {
135
- setJobIds((prev) => {
136
- if (prev.includes(id)) return prev;
137
- return [...prev, id];
138
- });
139
- },
140
- [setJobIds],
141
- );
142
-
143
- const removeJob = useCallback(
144
- (id: string) => {
145
- removedJobIdsRef.current.add(id);
146
-
147
- // Sync sessionStorage immediately so removal survives unmount
148
- // (React state updater callbacks & effects are discarded on unmount)
149
- writeStoredIds(readStoredIds().filter((j) => j !== id));
150
- writeStoredResults(readStoredResults().filter((r) => r.jobId !== id));
151
- const cleared = readClearedJobIds();
152
- if (!cleared.includes(id)) writeClearedJobIds([...cleared, id]);
153
-
154
- setJobIds((prev) => prev.filter((j) => j !== id));
155
- setCompletedResults((prev) => prev.filter((r) => r.jobId !== id));
156
- trpcClient.ai.cancelJob.mutate({ jobId: id }).catch(() => {});
157
- },
158
- [setJobIds],
159
- );
160
-
161
- const resetAll = useCallback(() => {
162
- setError(null);
163
-
164
- // Sync: clear sessionStorage immediately (survives unmount)
165
- sessionStorage.removeItem(RESULTS_KEY);
166
- sessionStorage.removeItem(STORAGE_KEY);
167
-
168
- // Sync: merge current job IDs into cleared list (accumulate, don't overwrite)
169
- const current = readStoredIds();
170
- const existing = readClearedJobIds();
171
- writeClearedJobIds([...new Set([...existing, ...current])]);
172
-
173
- // Keep blocks in-memory, don't clear
174
- for (const id of current) removedJobIdsRef.current.add(id);
175
-
176
- setCompletedResults([]);
177
- setJobIds([]);
178
- processedJobStates.current = {};
179
- }, [setJobIds]);
180
-
181
- // Recover from sessionStorage on mount
182
- useEffect(() => {
183
- const saved = readStoredIds();
184
- if (saved.length > 0) setJobIdsState(saved);
185
- const savedResults = readStoredResults();
186
- if (savedResults.length > 0) setCompletedResults(savedResults);
187
-
188
- const clearedIds = readClearedJobIds();
189
- for (const id of clearedIds) {
190
- removedJobIdsRef.current.add(id);
191
- }
192
- }, []);
193
-
194
- // Persist completedResults to sessionStorage
195
- useEffect(() => {
196
- writeStoredResults(completedResults);
197
- }, [completedResults]);
198
-
199
- // Sync across hook instances via custom event
200
- useEffect(() => {
201
- const handler = (e: Event) => {
202
- const detail = (e as CustomEvent).detail as { ids: string[] };
203
- setJobIdsState(detail.ids);
204
- };
205
- window.addEventListener("labas:jobs-change", handler);
206
- return () => window.removeEventListener("labas:jobs-change", handler);
207
- }, []);
208
 
209
- // Fallback: check myJobs for active jobs
210
  const myJobsQuery = useQuery({
211
  ...trpc.ai.myJobs.queryOptions({ limit: 20, offset: 0 }),
212
  enabled: isAuthenticated,
213
  refetchInterval: 3000,
214
  });
215
 
216
- useEffect(() => {
217
- if (!myJobsQuery.data) return;
218
-
219
- const clearedIds = readClearedJobIds();
220
-
221
- const activeIds = myJobsQuery.data
222
- .filter(
223
- (j) =>
224
- ACTIVE_STATUSES.has(j.status) &&
225
- !removedJobIdsRef.current.has(j.id) &&
226
- !clearedIds.includes(j.id),
227
- )
228
- .map((j) => j.id);
229
- if (activeIds.length === 0) return;
230
-
231
- setJobIds((prev) => {
232
- const newIds = activeIds.filter((id: string) => !prev.includes(id));
233
- if (newIds.length === 0) return prev;
234
- return [...prev, ...newIds];
235
- });
236
- }, [myJobsQuery.data, setJobIds]);
237
-
238
- // Poll each active job individually
239
- const jobQueries = useQueries({
240
- queries: jobIds.map((jobId) => ({
241
- ...trpc.ai.getJobStatus.queryOptions({ jobId }),
242
- enabled: !!jobId && isAuthenticated,
243
- refetchInterval: (query: any) => {
244
- const data = query.state.data;
245
- if (!data) return 1000;
246
- if (isTerminal(data.status as string)) return false;
247
- return 1000;
248
- },
249
- })),
250
- });
251
-
252
- const activeJobs = useMemo(() => {
253
- return jobQueries
254
- .filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
255
- .map((q) => q.data as unknown as ActiveJob);
256
- }, [jobQueries]);
257
-
258
- const isGenerating = activeJobs.length > 0;
259
- const activeCount = activeJobs.length;
260
- const canAddMore = activeCount < MAX_PARALLEL;
261
 
262
- // Track completed results
263
  useEffect(() => {
264
- for (let i = 0; i < jobQueries.length; i++) {
265
- const query = jobQueries[i];
266
- const jobId = jobIds[i];
267
- if (!jobId || !query.data) continue;
268
 
269
- const data = query.data as Record<string, unknown>;
270
- const status = data.status as string;
271
-
272
- const prevStatus = processedJobStates.current[jobId];
273
- if (prevStatus === status) continue;
274
- processedJobStates.current[jobId] = status;
275
 
276
  const extractPkgId = (): string | null => {
277
  const rj = data.resultJson as Record<string, unknown> | null | undefined;
@@ -280,99 +57,88 @@ export function useGenerationJobs() {
280
  return typeof id === "string" ? id : null;
281
  };
282
 
283
- // Skip if already tracked as completed
284
- if (completedResults.some((r) => r.jobId === jobId && r.timestamp > 0)) {
285
- // Update if status changed to completed with better data
 
 
 
 
 
 
 
 
 
 
286
  if (status === "completed" && data.resultJson) {
287
- setCompletedResults((prev) =>
288
- prev.map((r) =>
289
- r.jobId === jobId
290
- ? {
291
- jobId,
292
- result: data.resultJson as GenerationResult,
293
- generatedPackageId: extractPkgId(),
294
- mode: (data.mode as string) ?? "quick",
295
- timestamp: Date.now(),
296
- }
297
- : r,
298
- ),
299
- );
300
  }
301
- continue;
302
  }
303
 
304
- // Partial ready or completed β†’ capture result
305
  if (status === "partial_ready" && data.resultJson) {
306
- setCompletedResults((prev) => {
307
- if (prev.some((r) => r.jobId === jobId)) return prev;
308
- return [
309
- ...prev,
310
- {
311
- jobId,
312
- result: data.resultJson as GenerationResult,
313
- generatedPackageId: extractPkgId(),
314
- mode: (data.mode as string) ?? "quick",
315
- timestamp: Date.now(),
316
- },
317
- ];
318
- });
319
  }
320
 
321
  if (status === "completed" && data.resultJson) {
322
- setCompletedResults((prev) => {
323
- const exists = prev.findIndex((r) => r.jobId === jobId);
324
- const entry: CompletedResult = {
325
- jobId,
326
- result: data.resultJson as GenerationResult,
327
- generatedPackageId: extractPkgId(),
328
- mode: (data.mode as string) ?? "quick",
329
- timestamp: Date.now(),
330
- };
331
- if (exists >= 0) {
332
- const updated = [...prev];
333
- updated[exists] = entry;
334
- return updated;
335
- }
336
- return [...prev, entry];
337
- });
338
  }
339
 
340
  if (status === "failed") {
341
  setError((data.errorMessage as string) ?? "Generation failed");
342
- setCompletedResults((prev) =>
343
- prev.filter((r) => r.jobId !== jobId),
344
- );
345
  }
346
 
347
  if (status === "cancelled") {
348
- removedJobIdsRef.current.add(jobId);
349
- setCompletedResults((prev) =>
350
- prev.filter((r) => r.jobId !== jobId),
351
- );
352
  }
353
- }
354
- }, [jobQueries, jobIds]);
355
 
356
- // Auto-remove terminal jobs from tracking after a delay
357
  useEffect(() => {
358
- const terminalIds = jobQueries
 
 
359
  .filter(
360
- (q) =>
361
- q.data && isTerminal((q.data as { status: string }).status),
 
 
362
  )
363
- .map((_q, i) => jobIds[i])
364
- .filter(Boolean);
 
 
 
365
 
366
- if (terminalIds.length === 0) return;
 
 
 
 
 
 
 
 
 
 
 
367
 
368
- const timers = terminalIds.map((id) =>
369
- setTimeout(() => {
370
- setJobIds((prev) => prev.filter((j) => j !== id));
371
- }, 5000),
372
- );
373
 
374
- return () => timers.forEach(clearTimeout);
375
- }, [jobQueries, jobIds, setJobIds]);
 
 
 
 
 
376
 
377
  return {
378
  jobIds,
@@ -383,7 +149,7 @@ export function useGenerationJobs() {
383
  canAddMore,
384
  error,
385
  addJob,
386
- removeJob,
387
  resetAll,
388
  setError,
389
  myJobsQuery,
 
1
+ import { useEffect, useCallback } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
  import { trpc, trpcClient } from "@/utils/trpc";
 
4
  import { authClient } from "@/lib/auth-client";
5
+ import { useJobState } from "./use-job-state";
6
+ import { usePollingTransport } from "./use-polling-transport";
7
+ import { MAX_PARALLEL, ACTIVE_STATUSES, isTerminal } from "./use-job-shared";
8
+ import type { GenerationResult } from "@labas/ai";
9
 
10
+ export type { ActiveJob, CompletedResult } from "./use-job-shared";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  export function useGenerationJobs() {
13
  const { data: session } = authClient.useSession();
14
  const isAuthenticated = !!session;
15
 
16
+ const {
17
+ jobIds,
18
+ completedResults,
19
+ error,
20
+ processedStates,
21
+ removedIds,
22
+ addJob,
23
+ removeJob,
24
+ resetAll,
25
+ setError,
26
+ trackStatus,
27
+ setResult,
28
+ clearResult,
29
+ } = useJobState();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
+ /* Fallback: discover active jobs from myJobs endpoint */
32
  const myJobsQuery = useQuery({
33
  ...trpc.ai.myJobs.queryOptions({ limit: 20, offset: 0 }),
34
  enabled: isAuthenticated,
35
  refetchInterval: 3000,
36
  });
37
 
38
+ /* Transport layer β€” polling now, swappable for WebSocket later */
39
+ const transport = usePollingTransport({ isAuthenticated });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
 
41
  useEffect(() => {
42
+ transport.subscribe(jobIds);
43
+ return () => transport.unsubscribe();
44
+ }, [jobIds, transport]);
 
45
 
46
+ useEffect(() => {
47
+ transport.onUpdate((event) => {
48
+ const { jobId, status, data } = event;
49
+ const prevStatus = processedStates[jobId];
50
+ if (prevStatus === status) return;
51
+ trackStatus(jobId, status);
52
 
53
  const extractPkgId = (): string | null => {
54
  const rj = data.resultJson as Record<string, unknown> | null | undefined;
 
57
  return typeof id === "string" ? id : null;
58
  };
59
 
60
+ const baseResult = {
61
+ jobId,
62
+ result: data.resultJson as GenerationResult,
63
+ generatedPackageId: extractPkgId(),
64
+ mode: (data.mode as string) ?? "quick",
65
+ timestamp: Date.now(),
66
+ };
67
+
68
+ const alreadyCompleted = completedResults.some(
69
+ (r) => r.jobId === jobId && r.timestamp > 0,
70
+ );
71
+
72
+ if (alreadyCompleted) {
73
  if (status === "completed" && data.resultJson) {
74
+ setResult(baseResult);
 
 
 
 
 
 
 
 
 
 
 
 
75
  }
76
+ return;
77
  }
78
 
 
79
  if (status === "partial_ready" && data.resultJson) {
80
+ if (!completedResults.some((r) => r.jobId === jobId)) {
81
+ setResult(baseResult);
82
+ }
 
 
 
 
 
 
 
 
 
 
83
  }
84
 
85
  if (status === "completed" && data.resultJson) {
86
+ setResult(baseResult);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
 
89
  if (status === "failed") {
90
  setError((data.errorMessage as string) ?? "Generation failed");
91
+ clearResult(jobId);
 
 
92
  }
93
 
94
  if (status === "cancelled") {
95
+ clearResult(jobId);
 
 
 
96
  }
97
+ });
98
+ }, [transport, processedStates, completedResults, trackStatus, setResult, setError, clearResult]);
99
 
100
+ /* Merge discovered jobs from myJobs fallback */
101
  useEffect(() => {
102
+ if (!myJobsQuery.data) return;
103
+ const clearedIds = new Set<string>();
104
+ const discovered = myJobsQuery.data
105
  .filter(
106
+ (j) =>
107
+ ACTIVE_STATUSES.has(j.status) &&
108
+ !removedIds.has(j.id) &&
109
+ !clearedIds.has(j.id),
110
  )
111
+ .map((j) => j.id);
112
+ for (const id of discovered) {
113
+ if (!jobIds.includes(id)) addJob(id);
114
+ }
115
+ }, [myJobsQuery.data, removedIds, jobIds, addJob]);
116
 
117
+ /* Auto-remove terminal jobs after delay */
118
+ useEffect(() => {
119
+ const timers = jobIds
120
+ .filter((id) => {
121
+ const ev = processedStates[id];
122
+ return ev && isTerminal(ev);
123
+ })
124
+ .map((id) =>
125
+ setTimeout(() => removeJob(id), 5000),
126
+ );
127
+ return () => timers.forEach(clearTimeout);
128
+ }, [processedStates, jobIds, removeJob]);
129
 
130
+ const activeJobs = transport.activeJobs;
131
+ const activeCount = activeJobs.length;
132
+ const canAddMore = activeCount < MAX_PARALLEL;
133
+ const isGenerating = activeCount > 0;
 
134
 
135
+ const wrappedRemoveJob = useCallback(
136
+ (id: string) => {
137
+ trpcClient.ai.cancelJob.mutate({ jobId: id }).catch(() => {});
138
+ removeJob(id);
139
+ },
140
+ [removeJob],
141
+ );
142
 
143
  return {
144
  jobIds,
 
149
  canAddMore,
150
  error,
151
  addJob,
152
+ removeJob: wrappedRemoveJob,
153
  resetAll,
154
  setError,
155
  myJobsQuery,
apps/web/src/hooks/use-job-shared.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Shared types and constants for job management hooks. */
2
+
3
+ export const STORAGE_KEY = "labas_active_jobs";
4
+ export const RESULTS_KEY = "labas_completed_results";
5
+ export const CLEARED_JOBS_KEY = "labas_cleared_jobs";
6
+ export const MAX_PARALLEL = 3;
7
+
8
+ export interface ActiveJob {
9
+ id: string;
10
+ status: string;
11
+ progress: number;
12
+ progressMessage: string;
13
+ mode: string;
14
+ logs: unknown[];
15
+ resultJson: unknown;
16
+ errorMessage?: string;
17
+ examTypeId?: string;
18
+ sectionTypeId?: string;
19
+ questionCount?: number;
20
+ }
21
+
22
+ export interface CompletedResult {
23
+ jobId: string;
24
+ result: import("@labas/ai").GenerationResult;
25
+ generatedPackageId: string | null;
26
+ examType?: string;
27
+ section?: string;
28
+ mode: string;
29
+ timestamp: number;
30
+ }
31
+
32
+ /** Event emitted by a JobTransport when a job's status changes. */
33
+ export interface JobTransportEvent {
34
+ jobId: string;
35
+ status: string;
36
+ data: Record<string, unknown>;
37
+ }
38
+
39
+ /** Abstract transport for receiving real-time job updates.
40
+ * Concrete adapters: PollingTransport (now), WebSocketTransport (future). */
41
+ export interface JobTransport {
42
+ /** Start listening for updates on the given job IDs. */
43
+ subscribe(jobIds: string[]): void;
44
+ /** Stop listening for updates. */
45
+ unsubscribe(): void;
46
+ /** Register a callback for status-change events. */
47
+ onUpdate(callback: (event: JobTransportEvent) => void): void;
48
+ /** Register a callback for transport-level errors. */
49
+ onError(callback: (error: Error) => void): void;
50
+ /** Active jobs derived from latest events. */
51
+ readonly activeJobs: ActiveJob[];
52
+ }
53
+
54
+ export const ACTIVE_STATUSES = new Set([
55
+ "pending",
56
+ "running",
57
+ "running_fast",
58
+ "partial_ready",
59
+ "running_quality",
60
+ ]);
61
+
62
+ export const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
63
+
64
+ export function isTerminal(status: string): boolean {
65
+ return TERMINAL_STATUSES.has(status);
66
+ }
apps/web/src/hooks/use-job-state.ts ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useReducer, useRef } from "react";
2
+ import {
3
+ type CompletedResult,
4
+ STORAGE_KEY,
5
+ RESULTS_KEY,
6
+ CLEARED_JOBS_KEY,
7
+ } from "./use-job-shared";
8
+
9
+ /* ── Pure storage helpers (testable seam) ── */
10
+
11
+ function readStoredIds(): string[] {
12
+ try {
13
+ const raw = sessionStorage.getItem(STORAGE_KEY);
14
+ return raw ? (JSON.parse(raw) as string[]) : [];
15
+ } catch {
16
+ return [];
17
+ }
18
+ }
19
+
20
+ function writeStoredIds(ids: string[]) {
21
+ if (ids.length === 0) sessionStorage.removeItem(STORAGE_KEY);
22
+ else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
23
+ }
24
+
25
+ function readStoredResults(): CompletedResult[] {
26
+ try {
27
+ const raw = sessionStorage.getItem(RESULTS_KEY);
28
+ return raw ? (JSON.parse(raw) as CompletedResult[]) : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function writeStoredResults(results: CompletedResult[]) {
35
+ if (results.length === 0) sessionStorage.removeItem(RESULTS_KEY);
36
+ else sessionStorage.setItem(RESULTS_KEY, JSON.stringify(results));
37
+ }
38
+
39
+ function readClearedJobIds(): string[] {
40
+ try {
41
+ const raw = sessionStorage.getItem(CLEARED_JOBS_KEY);
42
+ return raw ? (JSON.parse(raw) as string[]) : [];
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ function writeClearedJobIds(ids: string[]) {
49
+ if (ids.length === 0) sessionStorage.removeItem(CLEARED_JOBS_KEY);
50
+ else sessionStorage.setItem(CLEARED_JOBS_KEY, JSON.stringify(ids));
51
+ }
52
+
53
+ function broadcastJobs(ids: string[]) {
54
+ window.dispatchEvent(
55
+ new CustomEvent("labas:jobs-change", { detail: { ids } }),
56
+ );
57
+ }
58
+
59
+ /* ── State shape ── */
60
+
61
+ interface State {
62
+ jobIds: string[];
63
+ completedResults: CompletedResult[];
64
+ error: string | null;
65
+ processedStates: Record<string, string>;
66
+ removedIds: Set<string>;
67
+ }
68
+
69
+ type Action =
70
+ | { type: "init"; jobIds: string[]; results: CompletedResult[]; clearedIds: string[] }
71
+ | { type: "addJob"; id: string }
72
+ | { type: "removeJob"; id: string }
73
+ | { type: "reset" }
74
+ | { type: "syncIds"; ids: string[] }
75
+ | { type: "trackStatus"; jobId: string; status: string }
76
+ | { type: "setResult"; result: CompletedResult }
77
+ | { type: "clearResult"; jobId: string }
78
+ | { type: "setError"; error: string | null };
79
+
80
+ function reducer(state: State, action: Action): State {
81
+ switch (action.type) {
82
+ case "init": {
83
+ const removed = new Set(action.clearedIds);
84
+ return {
85
+ ...state,
86
+ jobIds: action.jobIds,
87
+ completedResults: action.results,
88
+ removedIds: removed,
89
+ };
90
+ }
91
+
92
+ case "addJob": {
93
+ if (state.jobIds.includes(action.id)) return state;
94
+ const next = [...state.jobIds, action.id];
95
+ writeStoredIds(next);
96
+ broadcastJobs(next);
97
+ return { ...state, jobIds: next };
98
+ }
99
+
100
+ case "removeJob": {
101
+ const nextIds = state.jobIds.filter((j) => j !== action.id);
102
+ const nextResults = state.completedResults.filter((r) => r.jobId !== action.id);
103
+ const nextRemoved = new Set(state.removedIds).add(action.id);
104
+
105
+ writeStoredIds(nextIds);
106
+ writeStoredResults(nextResults);
107
+ const cleared = readClearedJobIds();
108
+ if (!cleared.includes(action.id)) writeClearedJobIds([...cleared, action.id]);
109
+
110
+ return {
111
+ ...state,
112
+ jobIds: nextIds,
113
+ completedResults: nextResults,
114
+ removedIds: nextRemoved,
115
+ };
116
+ }
117
+
118
+ case "reset": {
119
+ const current = readStoredIds();
120
+ const existing = readClearedJobIds();
121
+ writeClearedJobIds([...new Set([...existing, ...current])]);
122
+ sessionStorage.removeItem(RESULTS_KEY);
123
+ sessionStorage.removeItem(STORAGE_KEY);
124
+
125
+ const nextRemoved = new Set(state.removedIds);
126
+ for (const id of current) nextRemoved.add(id);
127
+
128
+ return {
129
+ ...state,
130
+ jobIds: [],
131
+ completedResults: [],
132
+ error: null,
133
+ processedStates: {},
134
+ removedIds: nextRemoved,
135
+ };
136
+ }
137
+
138
+ case "syncIds": {
139
+ return { ...state, jobIds: action.ids };
140
+ }
141
+
142
+ case "trackStatus": {
143
+ return {
144
+ ...state,
145
+ processedStates: { ...state.processedStates, [action.jobId]: action.status },
146
+ };
147
+ }
148
+
149
+ case "setResult": {
150
+ const idx = state.completedResults.findIndex((r) => r.jobId === action.result.jobId);
151
+ if (idx >= 0) {
152
+ const updated = [...state.completedResults];
153
+ updated[idx] = action.result;
154
+ return { ...state, completedResults: updated };
155
+ }
156
+ return { ...state, completedResults: [...state.completedResults, action.result] };
157
+ }
158
+
159
+ case "clearResult": {
160
+ return {
161
+ ...state,
162
+ completedResults: state.completedResults.filter((r) => r.jobId !== action.jobId),
163
+ };
164
+ }
165
+
166
+ case "setError": {
167
+ return { ...state, error: action.error };
168
+ }
169
+
170
+ default:
171
+ return state;
172
+ }
173
+ }
174
+
175
+ /* ── Hook ── */
176
+
177
+ export function useJobState() {
178
+ const [state, dispatch] = useReducer(reducer, {
179
+ jobIds: [],
180
+ completedResults: [],
181
+ error: null,
182
+ processedStates: {},
183
+ removedIds: new Set<string>(),
184
+ });
185
+
186
+ const stateRef = useRef(state);
187
+ stateRef.current = state;
188
+
189
+ /* Recover from sessionStorage on mount */
190
+ useEffect(() => {
191
+ const saved = readStoredIds();
192
+ const savedResults = readStoredResults();
193
+ const clearedIds = readClearedJobIds();
194
+ dispatch({ type: "init", jobIds: saved, results: savedResults, clearedIds });
195
+ }, []);
196
+
197
+ /* Persist completedResults */
198
+ useEffect(() => {
199
+ writeStoredResults(state.completedResults);
200
+ }, [state.completedResults]);
201
+
202
+ /* Sync across hook instances via custom event */
203
+ useEffect(() => {
204
+ const handler = (e: Event) => {
205
+ const detail = (e as CustomEvent).detail as { ids: string[] };
206
+ dispatch({ type: "syncIds", ids: detail.ids });
207
+ };
208
+ window.addEventListener("labas:jobs-change", handler);
209
+ return () => window.removeEventListener("labas:jobs-change", handler);
210
+ }, []);
211
+
212
+ const addJob = useCallback((id: string) => dispatch({ type: "addJob", id }), []);
213
+ const removeJob = useCallback((id: string) => dispatch({ type: "removeJob", id }), []);
214
+ const resetAll = useCallback(() => dispatch({ type: "reset" }), []);
215
+ const setError = useCallback((error: string | null) => dispatch({ type: "setError", error }), []);
216
+ const trackStatus = useCallback(
217
+ (jobId: string, status: string) => dispatch({ type: "trackStatus", jobId, status }),
218
+ [],
219
+ );
220
+ const setResult = useCallback(
221
+ (result: CompletedResult) => dispatch({ type: "setResult", result }),
222
+ [],
223
+ );
224
+ const clearResult = useCallback(
225
+ (jobId: string) => dispatch({ type: "clearResult", jobId }),
226
+ [],
227
+ );
228
+
229
+ return {
230
+ jobIds: state.jobIds,
231
+ completedResults: state.completedResults,
232
+ error: state.error,
233
+ processedStates: state.processedStates,
234
+ removedIds: state.removedIds,
235
+ addJob,
236
+ removeJob,
237
+ resetAll,
238
+ setError,
239
+ trackStatus,
240
+ setResult,
241
+ clearResult,
242
+ };
243
+ }
apps/web/src/hooks/use-package-builder.ts CHANGED
@@ -2,6 +2,7 @@ import { useMutation } from "@tanstack/react-query";
2
  import { useNavigate } from "@tanstack/react-router";
3
  import { trpc } from "@/utils/trpc";
4
  import { toast } from "sonner";
 
5
 
6
  export function usePackageBuilder() {
7
  const navigate = useNavigate();
@@ -14,7 +15,7 @@ export function usePackageBuilder() {
14
 
15
  const handleCreatePackage = async (params: {
16
  selectedIds: Set<string>;
17
- questions: Array<{ id: string; examTypeId: string; sectionTypeId: string; sectionTypeName?: string | null; [key: string]: any }>;
18
  title: string;
19
  description: string;
20
  isPublic: boolean;
@@ -51,8 +52,8 @@ export function usePackageBuilder() {
51
  }
52
 
53
  navigate({ to: "/package/$id", params: { id: pkg.id } });
54
- } catch (err: any) {
55
- toast.error("Gagal membuat paket", { description: err.message });
56
  }
57
  };
58
 
@@ -106,8 +107,8 @@ export function usePackageBuilder() {
106
  }
107
 
108
  navigate({ to: "/package/$id", params: { id: pkg.id } });
109
- } catch (err: any) {
110
- toast.error("Gagal membuat paket", { description: err.message });
111
  }
112
  };
113
 
 
2
  import { useNavigate } from "@tanstack/react-router";
3
  import { trpc } from "@/utils/trpc";
4
  import { toast } from "sonner";
5
+ import { getErrorMessage } from "@/lib/error-utils";
6
 
7
  export function usePackageBuilder() {
8
  const navigate = useNavigate();
 
15
 
16
  const handleCreatePackage = async (params: {
17
  selectedIds: Set<string>;
18
+ questions: Array<{ id: string; examTypeId: string; sectionTypeId: string; sectionTypeName?: string | null; [key: string]: unknown }>;
19
  title: string;
20
  description: string;
21
  isPublic: boolean;
 
52
  }
53
 
54
  navigate({ to: "/package/$id", params: { id: pkg.id } });
55
+ } catch (err: unknown) {
56
+ toast.error("Gagal membuat paket", { description: getErrorMessage(err) });
57
  }
58
  };
59
 
 
107
  }
108
 
109
  navigate({ to: "/package/$id", params: { id: pkg.id } });
110
+ } catch (err: unknown) {
111
+ toast.error("Gagal membuat paket", { description: getErrorMessage(err) });
112
  }
113
  };
114
 
apps/web/src/hooks/use-polling-transport.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { useQueries } from "@tanstack/react-query";
3
+ import { trpc } from "@/utils/trpc";
4
+ import { type ActiveJob, type JobTransport, type JobTransportEvent, isTerminal } from "./use-job-shared";
5
+
6
+ /** useQueries + .map() breaks contextual typing for refetchInterval; accept `unknown` so the callback is a valid supertype of TanStack's `Query` parameter. */
7
+ function jobStatusRefetchInterval(query: unknown): number | false {
8
+ const data = (query as { state: { data: { status: string } | null | undefined } }).state.data;
9
+ if (data == null) return 1000;
10
+ if (isTerminal(data.status)) return false;
11
+ return 1000;
12
+ }
13
+
14
+ /** Polling-based implementation of JobTransport.
15
+ * Uses tRPC useQueries to poll each tracked job individually. */
16
+ export function usePollingTransport({
17
+ isAuthenticated,
18
+ }: {
19
+ isAuthenticated: boolean;
20
+ }): JobTransport {
21
+ const [jobIds, setJobIds] = useState<string[]>([]);
22
+ const [activeJobs, setActiveJobs] = useState<ActiveJob[]>([]);
23
+ const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
24
+ const onErrorRef = useRef<(error: Error) => void>(undefined);
25
+
26
+ const jobQueries = useQueries({
27
+ queries: jobIds.map((jobId) => ({
28
+ ...trpc.ai.getJobStatus.queryOptions({ jobId }),
29
+ enabled: !!jobId && isAuthenticated,
30
+ refetchInterval: jobStatusRefetchInterval,
31
+ })),
32
+ });
33
+
34
+ useEffect(() => {
35
+ const nextActive = jobQueries
36
+ .filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
37
+ .map((q) => q.data as unknown as ActiveJob);
38
+ setActiveJobs(nextActive);
39
+
40
+ // Emit events for status changes
41
+ for (let i = 0; i < jobQueries.length; i++) {
42
+ const query = jobQueries[i];
43
+ const jobId = jobIds[i];
44
+ if (!jobId || !query.data) continue;
45
+ const data = query.data as Record<string, unknown>;
46
+ onUpdateRef.current?.({
47
+ jobId,
48
+ status: data.status as string,
49
+ data,
50
+ });
51
+ }
52
+ }, [jobQueries, jobIds]);
53
+
54
+ const subscribe = useCallback((ids: string[]) => {
55
+ setJobIds(ids);
56
+ }, []);
57
+
58
+ const unsubscribe = useCallback(() => {
59
+ setJobIds([]);
60
+ setActiveJobs([]);
61
+ }, []);
62
+
63
+ const onUpdate = useCallback((callback: (event: JobTransportEvent) => void) => {
64
+ onUpdateRef.current = callback;
65
+ }, []);
66
+
67
+ const onError = useCallback((callback: (error: Error) => void) => {
68
+ onErrorRef.current = callback;
69
+ }, []);
70
+
71
+ return {
72
+ subscribe,
73
+ unsubscribe,
74
+ onUpdate,
75
+ onError,
76
+ get activeJobs() {
77
+ return activeJobs;
78
+ },
79
+ };
80
+ }
apps/web/src/hooks/use-question-selection.ts CHANGED
@@ -3,7 +3,7 @@ import { useState, useMemo } from "react";
3
  interface Question {
4
  id: string;
5
  examTypeId: string;
6
- [key: string]: any;
7
  }
8
 
9
  export function useQuestionSelection(questions: Question[]) {
 
3
  interface Question {
4
  id: string;
5
  examTypeId: string;
6
+ [key: string]: unknown;
7
  }
8
 
9
  export function useQuestionSelection(questions: Question[]) {
apps/web/src/hooks/use-test-session.ts CHANGED
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
2
  import { useMutation, useQuery } from "@tanstack/react-query";
3
  import { useNavigate } from "@tanstack/react-router";
4
  import { trpc } from "@/utils/trpc";
 
5
 
6
  function getTimerKey(attemptId: string | null) {
7
  return attemptId ? `labas_attempt_timer_${attemptId}` : null;
@@ -168,9 +169,9 @@ export function useTestSession(packageId: string, existingAttemptId?: string) {
168
  // Load any saved timer (in case of refresh during attempt)
169
  const saved = loadElapsedTime(res.attemptId);
170
  if (saved > 0) setTimeElapsed(saved);
171
- } catch (err: any) {
172
  console.error("[useTestSession] Start attempt failed:", err);
173
- setStartError(err.message ?? "Gagal memulai latihan. Coba lagi.");
174
  }
175
  }, [packageId, startMutation]);
176
 
 
2
  import { useMutation, useQuery } from "@tanstack/react-query";
3
  import { useNavigate } from "@tanstack/react-router";
4
  import { trpc } from "@/utils/trpc";
5
+ import { getErrorMessage } from "@/lib/error-utils";
6
 
7
  function getTimerKey(attemptId: string | null) {
8
  return attemptId ? `labas_attempt_timer_${attemptId}` : null;
 
169
  // Load any saved timer (in case of refresh during attempt)
170
  const saved = loadElapsedTime(res.attemptId);
171
  if (saved > 0) setTimeElapsed(saved);
172
+ } catch (err: unknown) {
173
  console.error("[useTestSession] Start attempt failed:", err);
174
+ setStartError(getErrorMessage(err));
175
  }
176
  }, [packageId, startMutation]);
177
 
apps/web/src/lib/types.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Shared frontend types inferred from tRPC outputs.
2
+ * These are lightweight type aliases for data shapes used across components.
3
+ * For strict typing, prefer tRPC inferred types: RouterOutputs['router']['procedure'] */
4
+
5
+ export interface Question {
6
+ id: string;
7
+ examTypeId?: string;
8
+ sectionTypeId?: string;
9
+ format?: string;
10
+ passageText?: string | null;
11
+ questionText?: string;
12
+ options?: unknown;
13
+ correctAnswer?: string | null;
14
+ explanation?: string | null;
15
+ difficulty?: number | null;
16
+ isPublic?: boolean;
17
+ creatorUserId?: string | null;
18
+ examTypeName?: string | null;
19
+ sectionTypeName?: string | null;
20
+ skillTags?: string[] | null;
21
+ usageCount?: number;
22
+ avgRating?: number | null;
23
+ creatorName?: string | null;
24
+ source?: string | null;
25
+ _examType?: string;
26
+ _isRtl?: boolean;
27
+ _useFurigana?: boolean;
28
+ }
29
+
30
+ export interface Package {
31
+ id: string;
32
+ title?: string;
33
+ description?: string | null;
34
+ examTypeId?: string;
35
+ examTypeName?: string | null;
36
+ creatorUserId?: string | null;
37
+ creatorName?: string | null;
38
+ isPublic?: boolean;
39
+ totalQuestions?: number;
40
+ totalSections?: number;
41
+ estimatedDurationMin?: number | null;
42
+ usageCount?: number;
43
+ avgRating?: number | null;
44
+ }
45
+
46
+ export interface PackageSection {
47
+ id: string;
48
+ title?: string;
49
+ questions?: Question[];
50
+ packageId?: string;
51
+ packageTitle?: string | null;
52
+ examTypeName?: string | null;
53
+ sectionTypeName?: string | null;
54
+ sectionTypeId?: string;
55
+ orderIndex?: number;
56
+ }
57
+
58
+ export interface AttemptSection {
59
+ id: string;
60
+ title?: string;
61
+ questions?: Question[];
62
+ answers?: Array<{
63
+ questionId: string;
64
+ userAnswer?: string;
65
+ answer?: string;
66
+ isCorrect?: boolean | null;
67
+ partialScore?: number | null;
68
+ timeSpentSec?: number;
69
+ }>;
70
+ }
71
+
72
+ export interface Attempt {
73
+ id: string;
74
+ packageId?: string;
75
+ status?: string;
76
+ score?: number | null;
77
+ sections?: AttemptSection[];
78
+ }
79
+
80
+ export interface JobItem {
81
+ id: string;
82
+ status: string;
83
+ examTypeId?: string;
84
+ sectionTypeId?: string;
85
+ format?: string;
86
+ }
apps/web/src/routes/__root.tsx CHANGED
@@ -11,6 +11,17 @@ import { GlobalGenerationProgress } from "@/components/generate/GlobalGeneration
11
  import { ErrorFallback } from "@/components/ErrorFallback";
12
  import type { trpc } from "@/utils/trpc";
13
 
 
 
 
 
 
 
 
 
 
 
 
14
  import "../index.css";
15
 
16
  export interface RouterAppContext {
@@ -70,8 +81,10 @@ function RootComponent() {
70
  </div>
71
  ) : (
72
  <div className="min-h-screen bg-background relative">
 
73
  <Sidebar />
74
  <main
 
75
  className={`min-h-screen transition-all duration-300 relative z-0 ${collapsed ? "md:ml-16" : "md:ml-64"}`}
76
  >
77
  <Outlet />
 
11
  import { ErrorFallback } from "@/components/ErrorFallback";
12
  import type { trpc } from "@/utils/trpc";
13
 
14
+ function SkipLink() {
15
+ return (
16
+ <a
17
+ href="#main-content"
18
+ className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-[var(--clay-black)] focus:text-[var(--pure-white)] focus:rounded-[var(--radius-lg)] focus:font-semibold focus:text-sm focus:shadow-xl"
19
+ >
20
+ Lewati ke konten utama
21
+ </a>
22
+ );
23
+ }
24
+
25
  import "../index.css";
26
 
27
  export interface RouterAppContext {
 
81
  </div>
82
  ) : (
83
  <div className="min-h-screen bg-background relative">
84
+ <SkipLink />
85
  <Sidebar />
86
  <main
87
+ id="main-content"
88
  className={`min-h-screen transition-all duration-300 relative z-0 ${collapsed ? "md:ml-16" : "md:ml-64"}`}
89
  >
90
  <Outlet />
apps/web/src/routes/admin.credits.tsx CHANGED
@@ -20,6 +20,12 @@ function AdminCredits() {
20
  const [description, setDescription] = useState("");
21
  const queryClient = useQueryClient();
22
 
 
 
 
 
 
 
23
  const userSearch = useQuery(
24
  trpc.admin.listUsers.queryOptions(
25
  { search: debouncedSearch || undefined, limit: 10, offset: 0 },
@@ -81,6 +87,7 @@ function AdminCredits() {
81
  placeholder="Search by name or email..."
82
  value={search}
83
  onChange={(e) => handleSearch(e.target.value)}
 
84
  className="max-w-md"
85
  />
86
  {userSearch.data && userSearch.data.users.length > 0 && !selectedUserId && (
 
20
  const [description, setDescription] = useState("");
21
  const queryClient = useQueryClient();
22
 
23
+ function handleSearch(val: string) {
24
+ setSearch(val);
25
+ setSelectedUserId("");
26
+ setSelectedUserName("");
27
+ }
28
+
29
  const userSearch = useQuery(
30
  trpc.admin.listUsers.queryOptions(
31
  { search: debouncedSearch || undefined, limit: 10, offset: 0 },
 
87
  placeholder="Search by name or email..."
88
  value={search}
89
  onChange={(e) => handleSearch(e.target.value)}
90
+ aria-label="Cari user"
91
  className="max-w-md"
92
  />
93
  {userSearch.data && userSearch.data.users.length > 0 && !selectedUserId && (
apps/web/src/routes/admin.featured.tsx CHANGED
@@ -4,9 +4,11 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
4
  import { trpc } from "@/utils/trpc";
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
 
7
  import { toast } from "sonner";
8
  import { getErrorMessage } from "@/lib/error-utils";
9
  import { useDebouncedValue } from "@/hooks/use-debounced-value";
 
10
 
11
  export const Route = createFileRoute("/admin/featured")({
12
  component: AdminFeatured,
@@ -67,128 +69,163 @@ function AdminFeatured() {
67
 
68
  return (
69
  <div>
70
- <h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Editor's Pick</h1>
71
  <p className="text-[var(--warm-charcoal)] mb-6">Curate featured content for the homepage.</p>
72
 
73
- <div className="flex gap-2 mb-6 border-b border-[var(--oat-border)] pb-0">
74
- {tabs.map((t) => (
75
- <button
76
- key={t.key}
77
- onClick={() => { setTab(t.key); setSearch(""); setPage(1); }}
78
- className={`px-4 py-2.5 text-sm font-medium rounded-t-[var(--radius-lg)] transition-colors ${
79
- tab === t.key
80
- ? "bg-[var(--pure-white)] text-[var(--clay-black)] border border-[var(--oat-border)] border-b-[var(--pure-white)] -mb-[1px]"
81
- : "text-[var(--warm-charcoal)] hover:text-[var(--clay-black)]"
82
- }`}
83
- >
84
- {t.label}
85
- </button>
86
- ))}
87
- </div>
 
88
 
89
- {tab === "featured" && (
90
- <div className="space-y-8">
91
- <section>
92
- <div className="flex items-center gap-2 mb-4">
93
- <h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Packages</h2>
94
- <span className="text-sm text-[var(--warm-charcoal)]">({fPackages.length})</span>
95
- </div>
96
- {fPackages.length === 0 ? (
97
- <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
98
- ) : (
99
- <div className="space-y-2">
100
- {fPackages.map((pkg) => (
101
- <div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
102
- <div>
103
- <p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
104
- <p className="text-xs text-[var(--warm-charcoal)]">{pkg.examTypeId}</p>
105
- </div>
106
- <Button variant="outline" onClick={() => togglePkgMutation.mutate({ packageId: pkg.id })} disabled={togglePkgMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">Unfeature</Button>
107
- </div>
108
- ))}
109
  </div>
110
- )}
111
- </section>
112
-
113
- <section>
114
- <div className="flex items-center gap-2 mb-4">
115
- <h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Questions</h2>
116
- <span className="text-sm text-[var(--warm-charcoal)]">({fQuestions.length})</span>
117
- </div>
118
- {fQuestions.length === 0 ? (
119
- <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured questions.</p>
120
- ) : (
121
- <div className="space-y-2">
122
- {fQuestions.map((q) => (
123
- <div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
124
- <div className="flex-1 min-w-0">
125
- <p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
126
- <p className="text-xs text-[var(--warm-charcoal)]">{q.format} Β· {q.examTypeId}</p>
127
  </div>
128
- <Button variant="outline" className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleQMutation.mutate({ questionId: q.id })} disabled={toggleQMutation.isPending}>Unfeature</Button>
129
- </div>
130
- ))}
 
 
 
 
 
 
131
  </div>
132
- )}
133
- </section>
134
- </div>
135
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
- {tab !== "featured" && (
138
- <div>
139
  <Input
140
- placeholder={`Search ${tab}...`}
141
  value={search}
142
  onChange={(e) => handleSearch(e.target.value)}
143
  className="max-w-md mb-4"
144
  />
 
 
 
 
 
 
145
 
146
- {searchQuery.isLoading ? (
147
- <p className="text-sm text-[var(--warm-charcoal)] py-8 text-center">Searching...</p>
148
- ) : (
149
- <>
150
- <p className="text-sm text-[var(--warm-charcoal)] mb-3">
151
- {debouncedSearch ? `${searchQuery.data?.total ?? 0} results for "${debouncedSearch}"` : `Showing ${searchQuery.data?.total ?? 0} ${tab}`}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  </p>
153
- <div className="space-y-2">
154
- {(searchQuery.data?.items ?? []).map((item) => (
155
- <div key={item.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
156
- <div className="flex-1 min-w-0">
157
- <p className="font-medium text-[var(--clay-black)] truncate">
158
- {"title" in item ? item.title : item.questionText}
159
- </p>
160
- <p className="text-xs text-[var(--warm-charcoal)]">
161
- {"format" in item ? `${item.format} Β· ${item.examTypeId}` : item.examTypeId}
162
- {item.isFeatured && (
163
- <span className="ml-2 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]">Featured</span>
164
- )}
165
- </p>
166
- </div>
167
- <Button
168
- variant={item.isFeatured ? "outline" : "default"}
169
- className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs"
170
- onClick={() => {
171
- if (tab === "packages") togglePkgMutation.mutate({ packageId: item.id });
172
- else toggleQMutation.mutate({ questionId: item.id });
173
- }}
174
- disabled={togglePkgMutation.isPending || toggleQMutation.isPending}
175
- >
176
- {item.isFeatured ? "Unfeature" : "Feature"}
177
- </Button>
178
- </div>
179
- ))}
180
- </div>
181
- {searchQuery.data && (searchQuery.data.total as number) > limit && (
182
- <div className="flex items-center justify-center gap-4 mt-6">
183
- <Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
184
- <span className="text-sm text-[var(--warm-charcoal)]">Page {page} of {Math.ceil((searchQuery.data.total as number) / limit)}</span>
185
- <Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page * limit >= (searchQuery.data.total as number)}>Next</Button>
186
- </div>
187
- )}
188
- </>
189
- )}
190
  </div>
191
  )}
192
- </div>
193
  );
194
- }
 
4
  import { trpc } from "@/utils/trpc";
5
  import { Input } from "@labas/ui/components/input";
6
  import { Button } from "@labas/ui/components/button";
7
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@labas/ui/components/tabs";
8
  import { toast } from "sonner";
9
  import { getErrorMessage } from "@/lib/error-utils";
10
  import { useDebouncedValue } from "@/hooks/use-debounced-value";
11
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
12
 
13
  export const Route = createFileRoute("/admin/featured")({
14
  component: AdminFeatured,
 
69
 
70
  return (
71
  <div>
72
+ <h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Editor&apos;s Pick</h1>
73
  <p className="text-[var(--warm-charcoal)] mb-6">Curate featured content for the homepage.</p>
74
 
75
+ <Tabs
76
+ value={tab}
77
+ onValueChange={(v) => { setTab(v as Tab); setSearch(""); setPage(1); }}
78
+ className="mb-6"
79
+ >
80
+ <TabsList variant="line" className="h-10 bg-transparent border-b border-[var(--oat-border)]">
81
+ {tabs.map((t) => (
82
+ <TabsTrigger
83
+ key={t.key}
84
+ value={t.key}
85
+ className="px-4 py-2.5 text-sm font-medium rounded-t-[var(--radius-lg)] data-[active=true]:bg-[var(--pure-white)] data-[active=true]:text-[var(--clay-black)] data-[active=true]:border data-[active=true]:border-[var(--oat-border)] data-[active=true]:border-b-[var(--pure-white)] data-[active=true]:-mb-[1px] text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
86
+ >
87
+ {t.label}
88
+ </TabsTrigger>
89
+ ))}
90
+ </TabsList>
91
 
92
+ <TabsContent value="featured" className="mt-6">
93
+ <div className="space-y-8">
94
+ <section>
95
+ <div className="flex items-center gap-2 mb-4">
96
+ <h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Packages</h2>
97
+ <span className="text-sm text-[var(--warm-charcoal)]">({fPackages.length})</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  </div>
99
+ {fPackages.length === 0 ? (
100
+ <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
101
+ ) : (
102
+ <div className="space-y-2">
103
+ {fPackages.map((pkg) => (
104
+ <div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
105
+ <div>
106
+ <p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
107
+ <p className="text-xs text-[var(--warm-charcoal)]">{pkg.examTypeId}</p>
108
+ </div>
109
+ <Button variant="outline" onClick={() => togglePkgMutation.mutate({ packageId: pkg.id })} disabled={togglePkgMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">Unfeature</Button>
 
 
 
 
 
 
110
  </div>
111
+ ))}
112
+ </div>
113
+ )}
114
+ </section>
115
+
116
+ <section>
117
+ <div className="flex items-center gap-2 mb-4">
118
+ <h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Questions</h2>
119
+ <span className="text-sm text-[var(--warm-charcoal)]">({fQuestions.length})</span>
120
  </div>
121
+ {fQuestions.length === 0 ? (
122
+ <p className="text-sm text-[var(--warm-charcoal)] py-4">No featured questions.</p>
123
+ ) : (
124
+ <div className="space-y-2">
125
+ {fQuestions.map((q) => (
126
+ <div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
127
+ <div className="flex-1 min-w-0">
128
+ <p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
129
+ <p className="text-xs text-[var(--warm-charcoal)]">{q.format} Β· {q.examTypeId}</p>
130
+ </div>
131
+ <Button variant="outline" className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleQMutation.mutate({ questionId: q.id })} disabled={toggleQMutation.isPending}>Unfeature</Button>
132
+ </div>
133
+ ))}
134
+ </div>
135
+ )}
136
+ </section>
137
+ </div>
138
+ </TabsContent>
139
+
140
+ <TabsContent value="packages" className="mt-6">
141
+ <div className="relative flex-1">
142
+ <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
143
+ <Input
144
+ placeholder="Search packages..."
145
+ value={search}
146
+ onChange={(e) => setSearch(e.target.value)}
147
+ aria-label="Search packages"
148
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
149
+ />
150
+ </div>
151
+ <SearchResults query={searchQuery} tab="packages" page={page} setPage={setPage} limit={limit} togglePkgMutation={togglePkgMutation} toggleQMutation={toggleQMutation} />
152
+ </TabsContent>
153
 
154
+ <TabsContent value="questions" className="mt-6">
 
155
  <Input
156
+ placeholder="Search questions..."
157
  value={search}
158
  onChange={(e) => handleSearch(e.target.value)}
159
  className="max-w-md mb-4"
160
  />
161
+ <SearchResults query={searchQuery} tab="questions" page={page} setPage={setPage} limit={limit} togglePkgMutation={togglePkgMutation} toggleQMutation={toggleQMutation} />
162
+ </TabsContent>
163
+ </Tabs>
164
+ </div>
165
+ );
166
+ }
167
 
168
+ function SearchResults({
169
+ query,
170
+ tab,
171
+ page,
172
+ setPage,
173
+ limit,
174
+ togglePkgMutation,
175
+ toggleQMutation,
176
+ }: {
177
+ query: ReturnType<typeof useQuery>;
178
+ tab: "packages" | "questions";
179
+ page: number;
180
+ setPage: (fn: (p: number) => number) => void;
181
+ limit: number;
182
+ togglePkgMutation: { mutate: (args: { packageId: string }) => void; isPending: boolean };
183
+ toggleQMutation: { mutate: (args: { questionId: string }) => void; isPending: boolean };
184
+ }) {
185
+ if (query.isLoading) {
186
+ return <p className="text-sm text-[var(--warm-charcoal)] py-8 text-center">Searching...</p>;
187
+ }
188
+ const data = query.data as { items: Array<{ id: string; title?: string; questionText?: string; format?: string; examTypeId?: string; isFeatured?: boolean }>; total: number } | undefined;
189
+ return (
190
+ <>
191
+ <p className="text-sm text-[var(--warm-charcoal)] mb-3">
192
+ {query.isSuccess ? `Showing ${data?.total ?? 0} ${tab}` : ""}
193
+ </p>
194
+ <div className="space-y-2">
195
+ {(data?.items ?? []).map((item) => (
196
+ <div key={item.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
197
+ <div className="flex-1 min-w-0">
198
+ <p className="font-medium text-[var(--clay-black)] truncate">
199
+ {item.title ?? item.questionText}
200
  </p>
201
+ <p className="text-xs text-[var(--warm-charcoal)]">
202
+ {item.format ? `${item.format} Β· ${item.examTypeId}` : item.examTypeId}
203
+ {item.isFeatured && (
204
+ <span className="ml-2 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]">Featured</span>
205
+ )}
206
+ </p>
207
+ </div>
208
+ <Button
209
+ variant={item.isFeatured ? "outline" : "default"}
210
+ className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs"
211
+ onClick={() => {
212
+ if (tab === "packages") togglePkgMutation.mutate({ packageId: item.id });
213
+ else toggleQMutation.mutate({ questionId: item.id });
214
+ }}
215
+ disabled={togglePkgMutation.isPending || toggleQMutation.isPending}
216
+ >
217
+ {item.isFeatured ? "Unfeature" : "Feature"}
218
+ </Button>
219
+ </div>
220
+ ))}
221
+ </div>
222
+ {data && data.total > limit && (
223
+ <div className="flex items-center justify-center gap-4 mt-6">
224
+ <Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
225
+ <span className="text-sm text-[var(--warm-charcoal)]">Page {page} of {Math.ceil(data.total / limit)}</span>
226
+ <Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page * limit >= data.total}>Next</Button>
 
 
 
 
 
 
 
 
 
 
 
227
  </div>
228
  )}
229
+ </>
230
  );
231
+ }
apps/web/src/routes/admin.jobs.tsx CHANGED
@@ -80,7 +80,7 @@ function AdminJobs() {
80
  ) : (!jobs.data?.jobs || jobs.data.jobs.length === 0) ? (
81
  <tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">No jobs found.</td></tr>
82
  ) : (
83
- jobs.data.jobs.map((job: any) => (
84
  <tr key={job.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
85
  <td className="px-4 py-3 text-[var(--warm-charcoal)] font-mono text-xs">{job.userId?.slice(0, 10)}...</td>
86
  <td className="px-4 py-3">{job.examTypeId}</td>
 
80
  ) : (!jobs.data?.jobs || jobs.data.jobs.length === 0) ? (
81
  <tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">No jobs found.</td></tr>
82
  ) : (
83
+ jobs.data.jobs.map((job) => (
84
  <tr key={job.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
85
  <td className="px-4 py-3 text-[var(--warm-charcoal)] font-mono text-xs">{job.userId?.slice(0, 10)}...</td>
86
  <td className="px-4 py-3">{job.examTypeId}</td>
apps/web/src/routes/admin.moderation.tsx CHANGED
@@ -58,7 +58,7 @@ function AdminModeration() {
58
  ) : (!questions.data?.questions || questions.data.questions.length === 0) ? (
59
  <tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">No questions yet.</td></tr>
60
  ) : (
61
- questions.data.questions.map((q: any) => (
62
  <tr key={q.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
63
  <td className="px-4 py-3 max-w-xs">
64
  <p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
 
58
  ) : (!questions.data?.questions || questions.data.questions.length === 0) ? (
59
  <tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">No questions yet.</td></tr>
60
  ) : (
61
+ questions.data.questions.map((q) => (
62
  <tr key={q.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
63
  <td className="px-4 py-3 max-w-xs">
64
  <p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
apps/web/src/routes/admin.tsx CHANGED
@@ -60,7 +60,7 @@ function AdminLayout() {
60
  <span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
61
  </Link>
62
  </div>
63
- <h2 className="px-2 mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--warm-charcoal)]">Admin Panel</h2>
64
  <nav className="flex flex-col gap-1">
65
  {adminNavItems.map((item) => {
66
  const isActive = location.pathname === item.to;
 
60
  <span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
61
  </Link>
62
  </div>
63
+ <div className="px-2 mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--warm-charcoal)]">Admin Panel</div>
64
  <nav className="flex flex-col gap-1">
65
  {adminNavItems.map((item) => {
66
  const isActive = location.pathname === item.to;
apps/web/src/routes/admin.users.tsx CHANGED
@@ -46,8 +46,9 @@ function AdminUsers() {
46
  <Input
47
  placeholder="Search by name or email..."
48
  value={search}
49
- onChange={(e) => handleSearch(e.target.value)}
50
- className="max-w-sm"
 
51
  />
52
  </div>
53
  <table className="w-full text-sm">
 
46
  <Input
47
  placeholder="Search by name or email..."
48
  value={search}
49
+ onChange={(e) => setSearch(e.target.value)}
50
+ aria-label="Cari user"
51
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
52
  />
53
  </div>
54
  <table className="w-full text-sm">
apps/web/src/routes/analytics.tsx CHANGED
@@ -1,12 +1,12 @@
 
1
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
2
  import { authClient } from "@/lib/auth-client";
3
  import { useAnalytics } from "@/hooks/use-analytics";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
  import { OverviewCards } from "@/components/analytics/OverviewCards";
6
- import { ScoreTrendChart } from "@/components/analytics/ScoreTrendChart";
7
- import { BreakdownCharts } from "@/components/analytics/BreakdownCharts";
8
  import { WeaknessPanel } from "@/components/analytics/WeaknessPanel";
9
- import { TimeAnalyticsPanel } from "@/components/analytics/TimeAnalyticsPanel";
 
10
 
11
  export const Route = createFileRoute("/analytics")({
12
  component: AnalyticsComponent,
@@ -19,6 +19,15 @@ export const Route = createFileRoute("/analytics")({
19
  },
20
  });
21
 
 
 
 
 
 
 
 
 
 
22
  function AnalyticsComponent() {
23
  const {
24
  overview,
@@ -40,10 +49,7 @@ function AnalyticsComponent() {
40
  <div key={i} className="h-28 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
41
  ))}
42
  </div>
43
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
44
- <div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
45
- <div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
46
- </div>
47
  </div>
48
  );
49
  }
@@ -77,31 +83,25 @@ function AnalyticsComponent() {
77
  <OverviewCards data={overview.data} />
78
  </div>
79
 
80
- {/* Score Trend */}
81
- <div className="mb-8">
82
- <ScoreTrendChart data={trend.data} />
83
- </div>
84
-
85
- {/* Breakdown Charts */}
86
- <div className="mb-8">
87
- <BreakdownCharts
88
  byExamType={byExamType.data}
89
  bySectionType={bySectionType.data}
90
  byFormat={byFormat.data}
 
 
 
91
  />
92
- </div>
93
 
94
- {/* Weaknesses + Time Analytics */}
95
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
96
  <WeaknessPanel
97
  weaknesses={weaknesses.data?.weaknesses}
98
  recommendations={weaknesses.data?.recommendations}
99
  />
100
- <TimeAnalyticsPanel
101
- sectionTime={timeAnalytics.data?.sectionTime}
102
- formatTimeData={timeAnalytics.data?.formatTime}
103
- timeTrend={timeAnalytics.data?.timeTrend}
104
- />
105
  </div>
106
  </div>
107
  );
 
1
+ import { Suspense, lazy } from "react";
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
  import { useAnalytics } from "@/hooks/use-analytics";
5
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
6
  import { OverviewCards } from "@/components/analytics/OverviewCards";
 
 
7
  import { WeaknessPanel } from "@/components/analytics/WeaknessPanel";
8
+
9
+ const ChartsBundle = lazy(() => import("@/components/analytics/ChartsBundle"));
10
 
11
  export const Route = createFileRoute("/analytics")({
12
  component: AnalyticsComponent,
 
19
  },
20
  });
21
 
22
+ function ChartSkeleton() {
23
+ return (
24
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
25
+ <div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
26
+ <div className="h-80 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
27
+ </div>
28
+ );
29
+ }
30
+
31
  function AnalyticsComponent() {
32
  const {
33
  overview,
 
49
  <div key={i} className="h-28 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
50
  ))}
51
  </div>
52
+ <ChartSkeleton />
 
 
 
53
  </div>
54
  );
55
  }
 
83
  <OverviewCards data={overview.data} />
84
  </div>
85
 
86
+ {/* Charts β€” lazy loaded */}
87
+ <Suspense fallback={<ChartSkeleton />}>
88
+ <ChartsBundle
89
+ trend={trend.data}
 
 
 
 
90
  byExamType={byExamType.data}
91
  bySectionType={bySectionType.data}
92
  byFormat={byFormat.data}
93
+ sectionTime={timeAnalytics.data?.sectionTime}
94
+ formatTimeData={timeAnalytics.data?.formatTime}
95
+ timeTrend={timeAnalytics.data?.timeTrend}
96
  />
97
+ </Suspense>
98
 
99
+ {/* Weaknesses */}
100
+ <div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
101
  <WeaknessPanel
102
  weaknesses={weaknesses.data?.weaknesses}
103
  recommendations={weaknesses.data?.recommendations}
104
  />
 
 
 
 
 
105
  </div>
106
  </div>
107
  );
apps/web/src/routes/attempt.$id.tsx CHANGED
@@ -7,12 +7,29 @@ import { Button } from "@labas/ui/components/button";
7
  import { Card, CardContent } from "@labas/ui/components/card";
8
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
9
  import { formatTime } from "@/lib/time";
 
10
  import { QuestionReviewCard } from "@/components/attempt/QuestionReviewCard";
11
  import { ReviewFilterBar } from "@/components/attempt/ReviewFilterBar";
12
  import { SkillBreakdown } from "@/components/attempt/SkillBreakdown";
13
 
14
  type FilterStatus = "all" | "wrong" | "correct" | "marked";
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  export const Route = createFileRoute("/attempt/$id")({
17
  component: AttemptResultComponent,
18
  beforeLoad: async () => {
@@ -60,24 +77,24 @@ function AttemptResultComponent() {
60
  // ── Flatten all questions with section context ──
61
  const allQuestions = useMemo(() => {
62
  if (!attempt?.sections) return [];
63
- return attempt.sections.flatMap((sec: any, secIdx: number) =>
64
- sec.questions.map((q: any, qIdx: number) => ({
65
  q,
66
  secIdx,
67
  qIdx,
68
- ans: sec.answers.find((a: any) => a.questionId === q.id) ?? null,
69
  })),
70
  );
71
  }, [attempt]);
72
 
73
  // ── Filtered questions ──
74
  const partialCount = useMemo(
75
- () => allQuestions.filter(({ ans }: any) => ans?.partialScore != null && ans?.partialScore < 100).length,
76
  [allQuestions],
77
  );
78
 
79
  const filteredQuestions = useMemo(() => {
80
- return allQuestions.filter(({ q, ans }: any) => {
81
  if (filterStatus === "wrong" && ans?.isCorrect !== false) return false;
82
  if (filterStatus === "correct" && ans?.isCorrect !== true) return false;
83
  if (filterStatus === "marked") {
@@ -97,7 +114,7 @@ function AttemptResultComponent() {
97
  const next = !allExpanded;
98
  setAllExpanded(next);
99
  if (next) {
100
- setExpandedIds(new Set(filteredQuestions.map(({ q }: any) => q.id)));
101
  } else {
102
  setExpandedIds(new Set());
103
  }
@@ -210,6 +227,7 @@ function AttemptResultComponent() {
210
  <button
211
  key={star}
212
  onClick={() => ratePackageMutation.mutate({ packageId: attempt.packageId!, score: star })}
 
213
  className="transition-transform hover:scale-110"
214
  >
215
  <MaterialIcon
@@ -235,7 +253,7 @@ function AttemptResultComponent() {
235
  <div className="mb-8">
236
  <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
237
  <div className="space-y-3">
238
- {attempt.sections.map((sec: any) => {
239
  const secPct = sec.maxScore && sec.maxScore > 0
240
  ? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
241
  : 0;
@@ -273,9 +291,9 @@ function AttemptResultComponent() {
273
  <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">Klik topik untuk filter soal</span>
274
  </h2>
275
  <SkillBreakdown
276
- questions={allQuestions.map(({ q, ans }: any) => ({
277
- skillTags: q.skillTags,
278
- isCorrect: ans?.isCorrect,
279
  }))}
280
  onSkillClick={toggleSkill}
281
  activeSkills={filterSkills}
@@ -298,7 +316,7 @@ function AttemptResultComponent() {
298
  setFilterStatus={setFilterStatus}
299
  filterSkills={filterSkills}
300
  setFilterSkills={setFilterSkills}
301
- questions={allQuestions.map(({ q, ans }: any) => ({
302
  skillTags: q.skillTags ?? [],
303
  isCorrect: ans?.isCorrect,
304
  }))}
@@ -308,7 +326,7 @@ function AttemptResultComponent() {
308
  />
309
 
310
  <div className="space-y-3">
311
- {filteredQuestions.map(({ q, secIdx, qIdx, ans }: any) => (
312
  <QuestionReviewCard
313
  key={q.id}
314
  q={q}
 
7
  import { Card, CardContent } from "@labas/ui/components/card";
8
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
9
  import { formatTime } from "@/lib/time";
10
+ import type { Question } from "@/lib/types";
11
  import { QuestionReviewCard } from "@/components/attempt/QuestionReviewCard";
12
  import { ReviewFilterBar } from "@/components/attempt/ReviewFilterBar";
13
  import { SkillBreakdown } from "@/components/attempt/SkillBreakdown";
14
 
15
  type FilterStatus = "all" | "wrong" | "correct" | "marked";
16
 
17
+ interface AnswerItem {
18
+ questionId: string;
19
+ userAnswer?: string;
20
+ answer?: string;
21
+ isCorrect?: boolean | null;
22
+ partialScore?: number | null;
23
+ timeSpentSec?: number;
24
+ }
25
+
26
+ interface QuestionAnswer {
27
+ q: Question;
28
+ ans: AnswerItem | null;
29
+ secIdx: number;
30
+ qIdx: number;
31
+ }
32
+
33
  export const Route = createFileRoute("/attempt/$id")({
34
  component: AttemptResultComponent,
35
  beforeLoad: async () => {
 
77
  // ── Flatten all questions with section context ──
78
  const allQuestions = useMemo(() => {
79
  if (!attempt?.sections) return [];
80
+ return attempt.sections.flatMap((sec, secIdx: number) =>
81
+ sec.questions.map((q: Question, qIdx: number) => ({
82
  q,
83
  secIdx,
84
  qIdx,
85
+ ans: sec.answers.find((a: AnswerItem) => a.questionId === q.id) ?? null,
86
  })),
87
  );
88
  }, [attempt]);
89
 
90
  // ── Filtered questions ──
91
  const partialCount = useMemo(
92
+ () => allQuestions.filter(({ ans }) => ans?.partialScore != null && ans?.partialScore < 100).length,
93
  [allQuestions],
94
  );
95
 
96
  const filteredQuestions = useMemo(() => {
97
+ return allQuestions.filter(({ q, ans }) => {
98
  if (filterStatus === "wrong" && ans?.isCorrect !== false) return false;
99
  if (filterStatus === "correct" && ans?.isCorrect !== true) return false;
100
  if (filterStatus === "marked") {
 
114
  const next = !allExpanded;
115
  setAllExpanded(next);
116
  if (next) {
117
+ setExpandedIds(new Set(filteredQuestions.map(({ q }) => q.id)));
118
  } else {
119
  setExpandedIds(new Set());
120
  }
 
227
  <button
228
  key={star}
229
  onClick={() => ratePackageMutation.mutate({ packageId: attempt.packageId!, score: star })}
230
+ aria-label={`Nilai ${star} bintang`}
231
  className="transition-transform hover:scale-110"
232
  >
233
  <MaterialIcon
 
253
  <div className="mb-8">
254
  <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
255
  <div className="space-y-3">
256
+ {attempt.sections.map((sec) => {
257
  const secPct = sec.maxScore && sec.maxScore > 0
258
  ? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
259
  : 0;
 
291
  <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">Klik topik untuk filter soal</span>
292
  </h2>
293
  <SkillBreakdown
294
+ questions={allQuestions.map(({ q, ans }) => ({
295
+ skillTags: q.skillTags ?? [],
296
+ isCorrect: ans?.isCorrect ?? null,
297
  }))}
298
  onSkillClick={toggleSkill}
299
  activeSkills={filterSkills}
 
316
  setFilterStatus={setFilterStatus}
317
  filterSkills={filterSkills}
318
  setFilterSkills={setFilterSkills}
319
+ questions={allQuestions.map(({ q, ans }) => ({
320
  skillTags: q.skillTags ?? [],
321
  isCorrect: ans?.isCorrect,
322
  }))}
 
326
  />
327
 
328
  <div className="space-y-3">
329
+ {filteredQuestions.map(({ q, secIdx, qIdx, ans }) => (
330
  <QuestionReviewCard
331
  key={q.id}
332
  q={q}