rogasper commited on
Commit
f4209d5
·
1 Parent(s): 1798222

feat: integrate react-joyride for guided tours and add GettingStartedCard and PageGuide components for user onboarding. Enhance sidebar with a help tour button and update bank components to support new features. Implement bulk selection in SoalBrowser and improve question management with additional props for locking and selection states.

Browse files
apps/web/package.json CHANGED
@@ -33,6 +33,7 @@
33
  "next-themes": "catalog:",
34
  "react": "^19.2.5",
35
  "react-dom": "^19.2.5",
 
36
  "recharts": "^3.8.1",
37
  "sonner": "^2.0.7",
38
  "vite-plugin-pwa": "^1.2.0",
 
33
  "next-themes": "catalog:",
34
  "react": "^19.2.5",
35
  "react-dom": "^19.2.5",
36
+ "react-joyride": "^3.1.0",
37
  "recharts": "^3.8.1",
38
  "sonner": "^2.0.7",
39
  "vite-plugin-pwa": "^1.2.0",
apps/web/src/components/GettingStartedCard.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { Link } from "@tanstack/react-router";
3
+ import { Card, CardContent } from "@labas/ui/components/card";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+
6
+ const LS_KEY = "labas-getting-started-dismissed";
7
+
8
+ const steps = [
9
+ {
10
+ num: 1,
11
+ title: "Generate Soal",
12
+ desc: "Buat soal latihan AI sesuai exam yang kamu pilih. Pilih jenis ujian, section, format, dan topik.",
13
+ link: "/generate",
14
+ icon: "auto_awesome" as const,
15
+ color: "matcha",
16
+ },
17
+ {
18
+ num: 2,
19
+ title: "Buat Paket",
20
+ desc: "Kumpulkan soal dari Bank Soal jadi paket latihan. Bisa manual atau pakai Auto Bundle.",
21
+ link: "/bank",
22
+ icon: "folder" as const,
23
+ color: "slushie",
24
+ },
25
+ {
26
+ num: 3,
27
+ title: "Mulai Latihan",
28
+ desc: "Kerjakan paket soal, lihat skor, dan pantau perkembangan belajarmu.",
29
+ link: "/packages",
30
+ icon: "play_arrow" as const,
31
+ color: "lemon",
32
+ },
33
+ ];
34
+
35
+ const colorMap: Record<string, { bg: string; text: string; badge: string }> = {
36
+ matcha: { bg: "bg-[var(--matcha-300)]", text: "text-[var(--matcha-800)]", badge: "bg-[var(--matcha-300)]/30" },
37
+ slushie: { bg: "bg-[var(--slushie-500)]", text: "text-[var(--slushie-800)]", badge: "bg-[var(--slushie-500)]/20" },
38
+ lemon: { bg: "bg-[var(--lemon-400)]", text: "text-[var(--lemon-800)]", badge: "bg-[var(--lemon-400)]/30" },
39
+ };
40
+
41
+ export function GettingStartedCard() {
42
+ const [visible, setVisible] = useState(false);
43
+
44
+ useEffect(() => {
45
+ const dismissed = localStorage.getItem(LS_KEY) === "true";
46
+ if (!dismissed) setVisible(true);
47
+ }, []);
48
+
49
+ const dismiss = () => {
50
+ localStorage.setItem(LS_KEY, "true");
51
+ setVisible(false);
52
+ };
53
+
54
+ if (!visible) return null;
55
+
56
+ return (
57
+ <Card className="mb-8 bg-gradient-to-br from-[var(--matcha-300)]/20 to-[var(--slushie-500)]/20 border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)] relative overflow-hidden">
58
+ <div className="absolute -right-12 -top-12 w-48 h-48 ai-glow pointer-events-none opacity-20" />
59
+ <CardContent className="p-6 md:p-8">
60
+ <div className="flex items-start justify-between mb-6">
61
+ <div className="flex items-center gap-3">
62
+ <div className="h-12 w-12 bg-[var(--clay-black)] rounded-[var(--radius-xl)] flex items-center justify-center">
63
+ <MaterialIcon name="school" className="text-2xl text-[var(--pure-white)]" />
64
+ </div>
65
+ <div>
66
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
67
+ Mulai Belajar
68
+ </h2>
69
+ <p className="text-sm text-[var(--warm-charcoal)]">
70
+ Ikuti 3 langkah mudah untuk memulai latihan.
71
+ </p>
72
+ </div>
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" />
79
+ </button>
80
+ </div>
81
+
82
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
83
+ {steps.map((step) => {
84
+ const c = colorMap[step.color];
85
+ return (
86
+ <Link
87
+ key={step.num}
88
+ to={step.link}
89
+ className="flex items-start gap-4 p-4 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--matcha-400)] transition-all group clay-hover"
90
+ >
91
+ <div className={`h-10 w-10 ${c.bg} rounded-[var(--radius-lg)] flex items-center justify-center shrink-0`}>
92
+ <span className="text-sm font-black text-[var(--matcha-800)]">{step.num}</span>
93
+ </div>
94
+ <div className="flex-1 min-w-0">
95
+ <div className="flex items-center gap-2 mb-1">
96
+ <MaterialIcon name={step.icon} className={`text-sm ${c.text}`} />
97
+ <h3 className="font-headline font-bold text-[var(--clay-black)] group-hover:text-[var(--matcha-800)] transition-colors">
98
+ {step.title}
99
+ </h3>
100
+ </div>
101
+ <p className="text-xs text-[var(--warm-charcoal)] leading-relaxed">
102
+ {step.desc}
103
+ </p>
104
+ </div>
105
+ <MaterialIcon name="chevron_right" className="text-lg text-[var(--warm-silver)] group-hover:text-[var(--matcha-800)] shrink-0 self-center transition-colors" />
106
+ </Link>
107
+ );
108
+ })}
109
+ </div>
110
+ </CardContent>
111
+ </Card>
112
+ );
113
+ }
apps/web/src/components/PageGuide.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { Card, CardContent } from "@labas/ui/components/card";
3
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
4
+
5
+ interface GuideItem {
6
+ icon: string;
7
+ title: string;
8
+ desc: string;
9
+ }
10
+
11
+ interface PageGuideProps {
12
+ items: GuideItem[];
13
+ title?: string;
14
+ }
15
+
16
+ export function PageGuide({ items, title = "Petunjuk" }: PageGuideProps) {
17
+ const [open, setOpen] = useState(false);
18
+
19
+ return (
20
+ <Card className="mb-8 bg-[var(--pure-white)] border-2 border-[var(--slushie-500)]/30 rounded-[var(--radius-xl)] overflow-hidden">
21
+ <button
22
+ onClick={() => setOpen(!open)}
23
+ className="w-full flex items-center justify-between p-4 md:p-5 cursor-pointer hover:bg-[var(--oat-light)] transition-colors"
24
+ >
25
+ <div className="flex items-center gap-3">
26
+ <div className="h-9 w-9 bg-[var(--slushie-500)]/20 rounded-[var(--radius-lg)] flex items-center justify-center shrink-0">
27
+ <MaterialIcon name="help" className="text-lg text-[var(--slushie-800)]" />
28
+ </div>
29
+ <div className="text-left">
30
+ <h3 className="font-headline font-bold text-[var(--clay-black)]">{title}</h3>
31
+ <p className="text-xs text-[var(--warm-charcoal)]">
32
+ {open ? "Tutup petunjuk" : `Klik untuk lihat ${items.length} panduan`}
33
+ </p>
34
+ </div>
35
+ </div>
36
+ <MaterialIcon
37
+ name={open ? "expand_less" : "expand_more"}
38
+ className="text-xl text-[var(--warm-charcoal)]"
39
+ />
40
+ </button>
41
+
42
+ {open && (
43
+ <div className="px-4 md:px-5 pb-5 space-y-3 border-t border-[var(--oat-border)] pt-4">
44
+ {items.map((item, i) => (
45
+ <div key={i} className="flex items-start gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)]">
46
+ <div className="h-8 w-8 bg-[var(--pure-white)] rounded-[var(--radius-md)] flex items-center justify-center shrink-0 shadow-sm">
47
+ <MaterialIcon name={item.icon} className="text-sm text-[var(--matcha-600)]" />
48
+ </div>
49
+ <div className="flex-1 min-w-0">
50
+ <p className="text-sm font-bold text-[var(--clay-black)]">{item.title}</p>
51
+ <p className="text-xs text-[var(--warm-charcoal)] mt-0.5 leading-relaxed">{item.desc}</p>
52
+ </div>
53
+ </div>
54
+ ))}
55
+ </div>
56
+ )}
57
+ </Card>
58
+ );
59
+ }
apps/web/src/components/TourGuide.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 <strong>?</strong> 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
+
68
+ export function triggerGlobalTour() {
69
+ window.dispatchEvent(new CustomEvent(TRIGGER_GLOBAL));
70
+ }
71
+
72
+ // ── Page-specific tour ──
73
+
74
+ interface PageTourProps {
75
+ storageKey: string;
76
+ steps: Step[];
77
+ autoDelay?: number;
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
+
110
+ export function triggerPageTour(storageKey: string) {
111
+ window.dispatchEvent(new CustomEvent(storageKey));
112
+ }
113
+
114
+ // ── Floating help button ──
115
+
116
+ interface TourHelpButtonProps {
117
+ storageKey: string;
118
+ label?: string;
119
+ }
120
+
121
+ export function TourHelpButton({ storageKey, label = "Panduan Halaman" }: TourHelpButtonProps) {
122
+ return (
123
+ <button
124
+ onClick={() => triggerPageTour(storageKey)}
125
+ title={label}
126
+ className="fixed top-4 right-4 z-40 w-10 h-10 flex items-center justify-center bg-[var(--clay-black)] text-[var(--pure-white)] shadow-lg hover:bg-[var(--warm-charcoal)] transition-all clay-hover cursor-pointer hover:scale-105 active:scale-95 rounded-full"
127
+ >
128
+ <MaterialIcon name="help" className="text-lg" />
129
+ </button>
130
+ );
131
+ }
apps/web/src/components/bank/BundleSidebar.tsx CHANGED
@@ -21,6 +21,7 @@ interface BundleSidebarProps {
21
  bundleIsPublic: boolean;
22
  isCreating: boolean;
23
  autoBundleExamType: string | null;
 
24
  onSetTitle: (v: string) => void;
25
  onSetDescription: (v: string) => void;
26
  onSetIsPublic: (v: boolean) => void;
@@ -39,6 +40,7 @@ export function BundleSidebar({
39
  bundleIsPublic,
40
  isCreating,
41
  autoBundleExamType,
 
42
  onSetTitle,
43
  onSetDescription,
44
  onSetIsPublic,
@@ -51,17 +53,25 @@ export function BundleSidebar({
51
  const bundleCount = activeBundle.length;
52
 
53
  return (
54
- <div className="lg:col-span-4">
55
- <div className="sticky top-46 bg-[var(--pure-white)] rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow overflow-hidden flex flex-col max-h-[calc(100vh-6rem)]">
56
  {/* Header */}
57
  <div className="p-5 bg-[var(--clay-black)] text-[var(--pure-white)]">
58
  <div className="flex justify-between items-center mb-2">
59
  <h3 className="text-lg font-bold font-headline">Paket Saat Ini</h3>
60
  <span className="text-xs font-medium bg-[var(--matcha-600)]/30 px-2 py-1 rounded">DRAFT</span>
61
  </div>
62
- <p className="text-sm text-[var(--warm-silver)]">
63
- {bundleCount} {mode === "soal" ? "soal" : "section"} dipilih
64
- </p>
 
 
 
 
 
 
 
 
65
  </div>
66
 
67
  {/* Items List */}
 
21
  bundleIsPublic: boolean;
22
  isCreating: boolean;
23
  autoBundleExamType: string | null;
24
+ lockedExamType?: string | null;
25
  onSetTitle: (v: string) => void;
26
  onSetDescription: (v: string) => void;
27
  onSetIsPublic: (v: boolean) => void;
 
40
  bundleIsPublic,
41
  isCreating,
42
  autoBundleExamType,
43
+ lockedExamType,
44
  onSetTitle,
45
  onSetDescription,
46
  onSetIsPublic,
 
53
  const bundleCount = activeBundle.length;
54
 
55
  return (
56
+ <div data-tour="bank-sidebar" className="lg:col-span-4">
57
+ <div className="sticky top-32 bg-[var(--pure-white)] rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] clay-shadow overflow-hidden flex flex-col max-h-[calc(100vh-8rem)]">
58
  {/* Header */}
59
  <div className="p-5 bg-[var(--clay-black)] text-[var(--pure-white)]">
60
  <div className="flex justify-between items-center mb-2">
61
  <h3 className="text-lg font-bold font-headline">Paket Saat Ini</h3>
62
  <span className="text-xs font-medium bg-[var(--matcha-600)]/30 px-2 py-1 rounded">DRAFT</span>
63
  </div>
64
+ <div className="flex items-center gap-2">
65
+ <p className="text-sm text-[var(--warm-silver)]">
66
+ {bundleCount} {mode === "soal" ? "soal" : "section"} dipilih
67
+ </p>
68
+ {lockedExamType && (
69
+ <span className="text-[10px] font-medium bg-[var(--matcha-600)]/40 text-[var(--matcha-300)] px-2 py-0.5 rounded-full flex items-center gap-1">
70
+ <MaterialIcon name="lock" className="text-[10px]" />
71
+ {lockedExamType}
72
+ </span>
73
+ )}
74
+ </div>
75
  </div>
76
 
77
  {/* Items List */}
apps/web/src/components/bank/FilterBar.tsx CHANGED
@@ -19,6 +19,8 @@ interface FilterBarProps {
19
  activeChips: FilterChip[];
20
  hasFilters: boolean;
21
  isAdvancedOpen: boolean;
 
 
22
  onToggleAdvanced: () => void;
23
  onSetMode: (mode: "soal" | "section") => void;
24
  onSetTab: (tab: "mine" | "public") => void;
@@ -37,6 +39,8 @@ export function FilterBar({
37
  activeChips,
38
  hasFilters,
39
  isAdvancedOpen,
 
 
40
  onToggleAdvanced,
41
  onSetMode,
42
  onSetTab,
@@ -47,7 +51,7 @@ export function FilterBar({
47
  advancedFilters,
48
  }: FilterBarProps) {
49
  return (
50
- <div className="sticky top-0 z-30 bg-[var(--warm-cream)]/90 backdrop-blur-md border-b border-[var(--oat-border)] transition-all duration-200">
51
  <div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-4 pb-3 space-y-3">
52
  {/* ── Tier 1: Mode tabs ── */}
53
  <div className="flex items-center justify-between gap-3">
@@ -73,14 +77,31 @@ export function FilterBar({
73
 
74
  {/* ── Tier 2: Exam type chips ── */}
75
  <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
76
- <ChipButton active={examType === ""} onClick={() => onSetExamType("")}>
 
 
 
 
77
  Semua
78
  </ChipButton>
79
- {EXAM_TYPES.map((t) => (
80
- <ChipButton key={t.id} active={examType === t.id} onClick={() => onSetExamType(t.id)}>
81
- {t.name}
82
- </ChipButton>
83
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  </div>
85
 
86
  {/* ── Tier 3: Search + Advanced filter toggle ── */}
@@ -220,14 +241,17 @@ function TabButton({ active, onClick, children }: { active: boolean; onClick: ()
220
  );
221
  }
222
 
223
- function ChipButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
224
  return (
225
  <button
226
  onClick={onClick}
227
- className={`px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-all cursor-pointer ${
228
- active
229
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
230
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
 
 
 
231
  }`}
232
  >
233
  {children}
 
19
  activeChips: FilterChip[];
20
  hasFilters: boolean;
21
  isAdvancedOpen: boolean;
22
+ lockedExamType?: string | null;
23
+ dataTour?: string;
24
  onToggleAdvanced: () => void;
25
  onSetMode: (mode: "soal" | "section") => void;
26
  onSetTab: (tab: "mine" | "public") => void;
 
39
  activeChips,
40
  hasFilters,
41
  isAdvancedOpen,
42
+ lockedExamType,
43
+ dataTour,
44
  onToggleAdvanced,
45
  onSetMode,
46
  onSetTab,
 
51
  advancedFilters,
52
  }: FilterBarProps) {
53
  return (
54
+ <div data-tour={dataTour} className="sticky top-0 z-30 bg-[var(--warm-cream)]/90 backdrop-blur-md border-b border-[var(--oat-border)] transition-all duration-200">
55
  <div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-4 pb-3 space-y-3">
56
  {/* ── Tier 1: Mode tabs ── */}
57
  <div className="flex items-center justify-between gap-3">
 
77
 
78
  {/* ── Tier 2: Exam type chips ── */}
79
  <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
80
+ <ChipButton
81
+ active={examType === ""}
82
+ onClick={() => onSetExamType("")}
83
+ disabled={!!lockedExamType}
84
+ >
85
  Semua
86
  </ChipButton>
87
+ {EXAM_TYPES.map((t) => {
88
+ const isLocked = !!lockedExamType && lockedExamType !== t.id;
89
+ return (
90
+ <ChipButton
91
+ key={t.id}
92
+ active={examType === t.id}
93
+ onClick={() => {
94
+ if (!isLocked) onSetExamType(t.id);
95
+ }}
96
+ disabled={isLocked}
97
+ >
98
+ <span className="flex items-center gap-1.5">
99
+ {t.name}
100
+ {isLocked && <MaterialIcon name="lock" className="text-[10px]" />}
101
+ </span>
102
+ </ChipButton>
103
+ );
104
+ })}
105
  </div>
106
 
107
  {/* ── Tier 3: Search + Advanced filter toggle ── */}
 
241
  );
242
  }
243
 
244
+ function ChipButton({ active, onClick, children, disabled }: { active: boolean; onClick: () => void; children: React.ReactNode; disabled?: boolean }) {
245
  return (
246
  <button
247
  onClick={onClick}
248
+ disabled={disabled}
249
+ className={`px-4 py-2 rounded-full text-sm font-semibold whitespace-nowrap transition-all ${
250
+ disabled
251
+ ? "bg-[var(--oat-light)] text-[var(--warm-silver)] border-2 border-[var(--oat-border)] cursor-not-allowed opacity-50"
252
+ : active
253
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow cursor-pointer"
254
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)] cursor-pointer"
255
  }`}
256
  >
257
  {children}
apps/web/src/components/bank/QuestionCard.tsx CHANGED
@@ -6,6 +6,9 @@ interface QuestionCardProps {
6
  q: any;
7
  isFeatured?: boolean;
8
  isInBundle: boolean;
 
 
 
9
  onToggle: () => void;
10
  onOpenDetail: () => void;
11
  isOwner: boolean;
@@ -17,6 +20,9 @@ export function QuestionCard({
17
  q,
18
  isFeatured = false,
19
  isInBundle,
 
 
 
20
  onToggle,
21
  onOpenDetail,
22
  isOwner,
@@ -25,22 +31,42 @@ export function QuestionCard({
25
  }: QuestionCardProps) {
26
  return (
27
  <div
28
- onClick={onOpenDetail}
29
- className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col cursor-pointer transition-all ${
30
- isInBundle
31
- ? "border-[var(--clay-black)] bg-[var(--matcha-100)]"
32
- : "border-[var(--oat-border)]"
 
 
 
 
33
  }`}
34
  >
35
  <div className="p-5 flex flex-col h-full">
36
  <div className="flex items-start justify-between mb-3">
37
  <div className="flex gap-2 flex-wrap">
 
 
 
 
 
 
 
 
 
 
38
  <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
39
  {q.examTypeName}
40
  </span>
41
  <span className="px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
42
  {q.sectionTypeName}
43
  </span>
 
 
 
 
 
 
44
  </div>
45
  {q.avgRating && (
46
  <div className="flex items-center gap-1 text-[var(--lemon-700)]">
@@ -82,7 +108,7 @@ export function QuestionCard({
82
 
83
  {/* Actions row */}
84
  <div className="flex items-center justify-between mt-3 pt-3 border-t border-[var(--oat-border)]">
85
- {isOwner && (
86
  <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
87
  <button
88
  onClick={onTogglePublic}
@@ -102,20 +128,25 @@ export function QuestionCard({
102
  </button>
103
  </div>
104
  )}
 
105
  <Button
106
  size="sm"
 
107
  onClick={(e) => {
108
  e.stopPropagation();
109
- onToggle();
110
  }}
111
- className={`ml-auto rounded-[var(--radius-lg)] text-xs cursor-pointer ${
112
- isInBundle
113
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)]"
114
- : "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--matcha-400)]"
 
 
115
  }`}
116
  >
117
- {isInBundle ? "Hapus dari Paket" : "Tambah ke Paket"}
118
  </Button>
 
119
  </div>
120
  </div>
121
  </div>
 
6
  q: any;
7
  isFeatured?: boolean;
8
  isInBundle: boolean;
9
+ disabled?: boolean;
10
+ selected?: boolean;
11
+ bulkSelect?: boolean;
12
  onToggle: () => void;
13
  onOpenDetail: () => void;
14
  isOwner: boolean;
 
20
  q,
21
  isFeatured = false,
22
  isInBundle,
23
+ disabled = false,
24
+ selected = false,
25
+ bulkSelect = false,
26
  onToggle,
27
  onOpenDetail,
28
  isOwner,
 
31
  }: QuestionCardProps) {
32
  return (
33
  <div
34
+ onClick={() => { if (!disabled) onOpenDetail(); }}
35
+ className={`border-2 rounded-[var(--radius-xl)] h-full flex flex-col transition-all ${
36
+ disabled
37
+ ? "bg-[var(--oat-light)] border-[var(--oat-border)] opacity-40 cursor-not-allowed"
38
+ : selected
39
+ ? "bg-[var(--matcha-100)] border-[var(--matcha-600)] clay-hover cursor-pointer ring-2 ring-[var(--matcha-400)]"
40
+ : isInBundle
41
+ ? "bg-[var(--matcha-100)] border-[var(--clay-black)] clay-shadow clay-hover cursor-pointer"
42
+ : "bg-[var(--pure-white)] border-[var(--oat-border)] clay-shadow clay-hover cursor-pointer"
43
  }`}
44
  >
45
  <div className="p-5 flex flex-col h-full">
46
  <div className="flex items-start justify-between mb-3">
47
  <div className="flex gap-2 flex-wrap">
48
+ {bulkSelect && (
49
+ <span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
50
+ selected
51
+ ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
52
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
53
+ }`}>
54
+ <MaterialIcon name={selected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
55
+ {selected ? "Terpilih" : "Pilih"}
56
+ </span>
57
+ )}
58
  <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
59
  {q.examTypeName}
60
  </span>
61
  <span className="px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
62
  {q.sectionTypeName}
63
  </span>
64
+ {disabled && (
65
+ <span className="px-2 py-1 rounded-full bg-[var(--warm-silver)]/30 text-[var(--warm-charcoal)] text-[10px] font-semibold flex items-center gap-1">
66
+ <MaterialIcon name="lock" className="text-[10px]" />
67
+ Terkunci
68
+ </span>
69
+ )}
70
  </div>
71
  {q.avgRating && (
72
  <div className="flex items-center gap-1 text-[var(--lemon-700)]">
 
108
 
109
  {/* Actions row */}
110
  <div className="flex items-center justify-between mt-3 pt-3 border-t border-[var(--oat-border)]">
111
+ {isOwner && !bulkSelect && (
112
  <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
113
  <button
114
  onClick={onTogglePublic}
 
128
  </button>
129
  </div>
130
  )}
131
+ {!bulkSelect && (
132
  <Button
133
  size="sm"
134
+ disabled={disabled}
135
  onClick={(e) => {
136
  e.stopPropagation();
137
+ if (!disabled) onToggle();
138
  }}
139
+ className={`ml-auto rounded-[var(--radius-lg)] text-xs ${
140
+ disabled
141
+ ? "bg-[var(--oat-light)] text-[var(--warm-silver)] cursor-not-allowed"
142
+ : isInBundle
143
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] cursor-pointer"
144
+ : "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--matcha-400)] cursor-pointer"
145
  }`}
146
  >
147
+ {disabled ? "Tidak Tersedia" : isInBundle ? "Hapus dari Paket" : "Tambah ke Paket"}
148
  </Button>
149
+ )}
150
  </div>
151
  </div>
152
  </div>
apps/web/src/components/bank/SoalBrowser.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import { Card } from "@labas/ui/components/card";
2
  import { Button } from "@labas/ui/components/button";
3
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
@@ -6,35 +7,92 @@ import { QuestionCard } from "./QuestionCard";
6
  interface SoalBrowserProps {
7
  isLoading: boolean;
8
  questions: any[];
9
- totalPages: number;
10
- page: number;
11
  hasFilters: boolean;
12
  userId?: string;
 
 
 
13
  isQuestionInBundle: (id: string) => boolean;
14
  onToggleQuestion: (q: any) => void;
15
  onOpenDetail: (q: any) => void;
16
  onTogglePublic: (id: string) => void;
17
  onDelete: (id: string) => void;
18
- onSetPage: (page: number) => void;
19
  onClearFilters: () => void;
 
20
  }
21
 
22
  export function SoalBrowser({
23
  isLoading,
24
  questions,
25
- totalPages,
26
- page,
27
  hasFilters,
28
  userId,
 
 
 
29
  isQuestionInBundle,
30
  onToggleQuestion,
31
  onOpenDetail,
32
  onTogglePublic,
33
  onDelete,
34
- onSetPage,
35
  onClearFilters,
 
36
  }: SoalBrowserProps) {
37
- if (isLoading) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return (
39
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
40
  {Array.from({ length: 6 }).map((_, i) => (
@@ -44,6 +102,7 @@ export function SoalBrowser({
44
  );
45
  }
46
 
 
47
  if (questions.length === 0) {
48
  return (
49
  <div className="text-center py-20">
@@ -61,17 +120,80 @@ export function SoalBrowser({
61
  );
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  return (
65
  <>
66
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
 
 
 
67
  {questions[0] && (
68
  <div className="md:col-span-2">
69
  <QuestionCard
70
  q={questions[0]}
71
  isFeatured
72
  isInBundle={isQuestionInBundle(questions[0].id)}
73
- onToggle={() => onToggleQuestion(questions[0])}
74
- onOpenDetail={() => onOpenDetail(questions[0])}
 
 
 
75
  isOwner={questions[0].creatorUserId === userId}
76
  onTogglePublic={() => onTogglePublic(questions[0].id)}
77
  onDelete={() => onDelete(questions[0].id)}
@@ -83,8 +205,11 @@ export function SoalBrowser({
83
  key={q.id}
84
  q={q}
85
  isInBundle={isQuestionInBundle(q.id)}
86
- onToggle={() => onToggleQuestion(q)}
87
- onOpenDetail={() => onOpenDetail(q)}
 
 
 
88
  isOwner={q.creatorUserId === userId}
89
  onTogglePublic={() => onTogglePublic(q.id)}
90
  onDelete={() => onDelete(q.id)}
@@ -92,29 +217,21 @@ export function SoalBrowser({
92
  ))}
93
  </div>
94
 
95
- {totalPages > 1 && (
96
- <div className="flex items-center justify-center gap-2 mt-10">
97
- <Button
98
- variant="outline"
99
- onClick={() => onSetPage(Math.max(1, page - 1))}
100
- disabled={page <= 1}
101
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover cursor-pointer"
102
- >
103
- <MaterialIcon name="chevron_left" />
104
- </Button>
105
- <span className="text-sm text-[var(--warm-charcoal)] px-4">
106
- Halaman {page} dari {totalPages}
107
- </span>
108
- <Button
109
- variant="outline"
110
- onClick={() => onSetPage(Math.min(totalPages, page + 1))}
111
- disabled={page >= totalPages}
112
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover cursor-pointer"
113
- >
114
- <MaterialIcon name="chevron_right" />
115
- </Button>
116
  </div>
117
  )}
 
 
 
 
 
 
 
 
118
  </>
119
  );
120
  }
 
1
+ import { useState, useRef, useEffect } from "react";
2
  import { Card } from "@labas/ui/components/card";
3
  import { Button } from "@labas/ui/components/button";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
7
  interface SoalBrowserProps {
8
  isLoading: boolean;
9
  questions: any[];
10
+ hasMore: boolean;
11
+ isFetchingNextPage: boolean;
12
  hasFilters: boolean;
13
  userId?: string;
14
+ lockedExamType?: string | null;
15
+ tab: "mine" | "public";
16
+ filterKey: string;
17
  isQuestionInBundle: (id: string) => boolean;
18
  onToggleQuestion: (q: any) => void;
19
  onOpenDetail: (q: any) => void;
20
  onTogglePublic: (id: string) => void;
21
  onDelete: (id: string) => void;
22
+ onLoadMore: () => void;
23
  onClearFilters: () => void;
24
+ onBulkPublish?: (ids: string[]) => void;
25
  }
26
 
27
  export function SoalBrowser({
28
  isLoading,
29
  questions,
30
+ hasMore,
31
+ isFetchingNextPage,
32
  hasFilters,
33
  userId,
34
+ lockedExamType,
35
+ tab,
36
+ filterKey,
37
  isQuestionInBundle,
38
  onToggleQuestion,
39
  onOpenDetail,
40
  onTogglePublic,
41
  onDelete,
42
+ onLoadMore,
43
  onClearFilters,
44
+ onBulkPublish,
45
  }: SoalBrowserProps) {
46
+ const sentinelRef = useRef<HTMLDivElement>(null);
47
+ const isLocked = (q: any) => !!lockedExamType && q.examTypeId !== lockedExamType;
48
+
49
+ // ── Bulk mode ──
50
+ const [bulkMode, setBulkMode] = useState(false);
51
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
52
+
53
+ useEffect(() => {
54
+ setBulkMode(false);
55
+ setSelectedIds(new Set());
56
+ }, [filterKey]);
57
+
58
+ useEffect(() => {
59
+ const el = sentinelRef.current;
60
+ if (!el || !hasMore || isFetchingNextPage) return;
61
+ const observer = new IntersectionObserver(
62
+ (entries) => {
63
+ if (entries[0]?.isIntersecting) onLoadMore();
64
+ },
65
+ { rootMargin: "200px" },
66
+ );
67
+ observer.observe(el);
68
+ return () => observer.disconnect();
69
+ }, [hasMore, isFetchingNextPage, onLoadMore]);
70
+
71
+ const toggleSelect = (id: string) => {
72
+ setSelectedIds((prev) => {
73
+ const next = new Set(prev);
74
+ if (next.has(id)) next.delete(id);
75
+ else next.add(id);
76
+ return next;
77
+ });
78
+ };
79
+
80
+ const clearSelection = () => setSelectedIds(new Set());
81
+
82
+ const selectAll = () => {
83
+ setSelectedIds(new Set(questions.map((q: any) => q.id)));
84
+ };
85
+
86
+ const handleBulkPublish = () => {
87
+ if (onBulkPublish && selectedIds.size > 0) {
88
+ onBulkPublish(Array.from(selectedIds));
89
+ setBulkMode(false);
90
+ setSelectedIds(new Set());
91
+ }
92
+ };
93
+
94
+ // ── Loading state ──
95
+ if (isLoading && questions.length === 0) {
96
  return (
97
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
98
  {Array.from({ length: 6 }).map((_, i) => (
 
102
  );
103
  }
104
 
105
+ // ── Empty state ──
106
  if (questions.length === 0) {
107
  return (
108
  <div className="text-center py-20">
 
120
  );
121
  }
122
 
123
+ // ── Bulk mode toolbar ──
124
+ const renderBulkToolbar = () => {
125
+ if (!onBulkPublish || tab !== "mine") return null;
126
+ return (
127
+ <div className="flex items-center justify-between mb-4 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)] border-2 border-[var(--oat-border)]">
128
+ {bulkMode ? (
129
+ <>
130
+ <div className="flex items-center gap-3">
131
+ <button
132
+ onClick={clearSelection}
133
+ className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
134
+ >
135
+ Batalkan ({selectedIds.size})
136
+ </button>
137
+ <button
138
+ onClick={selectAll}
139
+ className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer"
140
+ >
141
+ Pilih Semua
142
+ </button>
143
+ </div>
144
+ <div className="flex items-center gap-2">
145
+ <Button
146
+ size="sm"
147
+ disabled={selectedIds.size === 0}
148
+ onClick={handleBulkPublish}
149
+ className="rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] text-xs cursor-pointer"
150
+ >
151
+ <MaterialIcon name="public" className="text-xs mr-1" />
152
+ Jadikan Publik ({selectedIds.size})
153
+ </Button>
154
+ <button
155
+ onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
156
+ className="text-xs text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer font-semibold ml-2"
157
+ >
158
+ Selesai
159
+ </button>
160
+ </div>
161
+ </>
162
+ ) : (
163
+ <>
164
+ <span className="text-xs font-semibold text-[var(--warm-charcoal)]">
165
+ {questions.length} soal
166
+ </span>
167
+ <button
168
+ onClick={() => setBulkMode(true)}
169
+ className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer flex items-center gap-1"
170
+ >
171
+ <MaterialIcon name="select_all" className="text-sm" />
172
+ Pilih Banyak
173
+ </button>
174
+ </>
175
+ )}
176
+ </div>
177
+ );
178
+ };
179
+
180
  return (
181
  <>
182
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
183
+ {renderBulkToolbar()}
184
+ <div className="md:col-span-2" />
185
+
186
  {questions[0] && (
187
  <div className="md:col-span-2">
188
  <QuestionCard
189
  q={questions[0]}
190
  isFeatured
191
  isInBundle={isQuestionInBundle(questions[0].id)}
192
+ disabled={bulkMode ? false : isLocked(questions[0])}
193
+ selected={bulkMode ? selectedIds.has(questions[0].id) : false}
194
+ bulkSelect={bulkMode}
195
+ onToggle={() => bulkMode ? toggleSelect(questions[0].id) : onToggleQuestion(questions[0])}
196
+ onOpenDetail={() => bulkMode ? toggleSelect(questions[0].id) : onOpenDetail(questions[0])}
197
  isOwner={questions[0].creatorUserId === userId}
198
  onTogglePublic={() => onTogglePublic(questions[0].id)}
199
  onDelete={() => onDelete(questions[0].id)}
 
205
  key={q.id}
206
  q={q}
207
  isInBundle={isQuestionInBundle(q.id)}
208
+ disabled={bulkMode ? false : isLocked(q)}
209
+ selected={bulkMode ? selectedIds.has(q.id) : false}
210
+ bulkSelect={bulkMode}
211
+ onToggle={() => bulkMode ? toggleSelect(q.id) : onToggleQuestion(q)}
212
+ onOpenDetail={() => bulkMode ? toggleSelect(q.id) : onOpenDetail(q)}
213
  isOwner={q.creatorUserId === userId}
214
  onTogglePublic={() => onTogglePublic(q.id)}
215
  onDelete={() => onDelete(q.id)}
 
217
  ))}
218
  </div>
219
 
220
+ {isFetchingNextPage && (
221
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
222
+ {Array.from({ length: 4 }).map((_, i) => (
223
+ <Card key={i} className="h-40 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
224
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  </div>
226
  )}
227
+
228
+ <div ref={sentinelRef} className="h-4" />
229
+
230
+ {!hasMore && questions.length > 0 && (
231
+ <p className="text-center text-sm text-[var(--warm-silver)] mt-8">
232
+ Semua soal telah dimuat ({questions.length} soal)
233
+ </p>
234
+ )}
235
  </>
236
  );
237
  }
apps/web/src/components/sidebar.tsx CHANGED
@@ -2,6 +2,7 @@ import { useState } from "react";
2
  import { Link, useLocation, useNavigate } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
  import { useSidebar } from "@/hooks/use-sidebar";
 
5
 
6
  interface NavItem {
7
  to: string;
@@ -63,9 +64,11 @@ function NavIcon({ name }: { name: string }) {
63
  }
64
 
65
  function NavLink({ item, isActive, collapsed }: { item: NavItem; isActive: boolean; collapsed: boolean }) {
 
66
  return (
67
  <Link
68
  to={item.to}
 
69
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all group clay-hover cursor-pointer ${
70
  isActive
71
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
@@ -152,6 +155,19 @@ export function Sidebar() {
152
  />
153
  );
154
  })}
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  {isLoggedIn ? (
156
  <button
157
  onClick={handleSignOut}
 
2
  import { Link, useLocation, useNavigate } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
  import { useSidebar } from "@/hooks/use-sidebar";
5
+ import { triggerGlobalTour } from "@/components/TourGuide";
6
 
7
  interface NavItem {
8
  to: string;
 
64
  }
65
 
66
  function NavLink({ item, isActive, collapsed }: { item: NavItem; isActive: boolean; collapsed: boolean }) {
67
+ const tourAttr = item.to !== "/" ? { "data-tour": `nav-${item.to.replace("/", "")}` } : {};
68
  return (
69
  <Link
70
  to={item.to}
71
+ {...tourAttr}
72
  className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all group clay-hover cursor-pointer ${
73
  isActive
74
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] font-semibold clay-shadow"
 
155
  />
156
  );
157
  })}
158
+
159
+ {/* Help Tour Button */}
160
+ <button
161
+ onClick={triggerGlobalTour}
162
+ className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover cursor-pointer text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
163
+ collapsed ? "justify-center py-3 px-2" : "py-3 px-3 text-left"
164
+ }`}
165
+ title={collapsed ? "Panduan" : undefined}
166
+ >
167
+ <NavIcon name="help" />
168
+ {!collapsed && <span className="text-sm font-medium">Panduan</span>}
169
+ </button>
170
+
171
  {isLoggedIn ? (
172
  <button
173
  onClick={handleSignOut}
apps/web/src/routes/__root.tsx CHANGED
@@ -8,6 +8,7 @@ import { Sidebar } from "@/components/sidebar";
8
  import { ThemeProvider } from "@/components/theme-provider";
9
  import { useSidebar } from "@/hooks/use-sidebar";
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
 
11
  import type { trpc } from "@/utils/trpc";
12
 
13
  import "../index.css";
@@ -72,6 +73,7 @@ function RootComponent() {
72
  </div>
73
  )}
74
  <GlobalGenerationProgress />
 
75
  <Toaster richColors />
76
  </ThemeProvider>
77
  <TanStackRouterDevtools position="bottom-left" />
 
8
  import { ThemeProvider } from "@/components/theme-provider";
9
  import { useSidebar } from "@/hooks/use-sidebar";
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
11
+ import { TourGuide } from "@/components/TourGuide";
12
  import type { trpc } from "@/utils/trpc";
13
 
14
  import "../index.css";
 
73
  </div>
74
  )}
75
  <GlobalGenerationProgress />
76
+ <TourGuide />
77
  <Toaster richColors />
78
  </ThemeProvider>
79
  <TanStackRouterDevtools position="bottom-left" />
apps/web/src/routes/bank.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect } from "@tanstack/react-router";
4
  import { z } from "zod";
@@ -10,6 +10,8 @@ import { usePackageBuilder } from "@/hooks/use-package-builder";
10
  import { useLocalStorageBoolean } from "@/hooks/use-local-storage-boolean";
11
  import { AutoBundleModal } from "@/components/bank/AutoBundleModal";
12
  import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
 
 
13
  import { FilterBar } from "@/components/bank/FilterBar";
14
  import { MobileFilterSheet } from "@/components/bank/MobileFilterSheet";
15
  import { AdvancedFilters } from "@/components/bank/AdvancedFilters";
@@ -17,6 +19,7 @@ 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
 
21
  const FILTER_ADVANCED_KEY = "labas-bank-filter-advanced";
22
 
@@ -30,7 +33,6 @@ export const Route = createFileRoute("/bank")({
30
  section: z.string().optional(),
31
  format: z.string().optional(),
32
  difficulty: z.coerce.number().optional(),
33
- page: z.coerce.number().optional(),
34
  }).parse,
35
  beforeLoad: async () => {
36
  const session = await authClient.getSession();
@@ -51,14 +53,18 @@ function BankComponent() {
51
  const userId = session?.user.id;
52
 
53
  const mode: Mode = search.mode ?? "soal";
54
- const tab: QuestionTab = search.tab ?? "mine";
55
  const searchText = search.search ?? "";
56
  const examType = search.examType ?? "";
57
  const section = search.section ?? "";
58
  const format = search.format ?? "";
59
  const difficulty = search.difficulty;
60
- const page = search.page ?? 1;
 
 
 
61
  const limit = 12;
 
62
 
63
  // ── Sidebar / Bundle State ──
64
  const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
@@ -88,12 +94,35 @@ function BankComponent() {
88
  ? { creatorUserId: userId }
89
  : { isPublic: true }),
90
  limit,
91
- offset: (page - 1) * limit,
92
  },
93
  { enabled: mode === "soal" },
94
  ),
95
  );
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  const sectionQuery = useQuery(
98
  trpc.combo.availableSections.queryOptions(
99
  {
@@ -124,6 +153,15 @@ function BankComponent() {
124
  onSuccess: () => questionQuery.refetch(),
125
  });
126
 
 
 
 
 
 
 
 
 
 
127
  // ── Navigation helpers ──
128
  const setMode = (newMode: Mode) => {
129
  navigate({
@@ -135,7 +173,6 @@ function BankComponent() {
135
  section: "",
136
  format: "",
137
  difficulty: undefined,
138
- page: 1,
139
  },
140
  });
141
  if (newMode === "soal") setBundleSections([]);
@@ -143,22 +180,19 @@ function BankComponent() {
143
  };
144
 
145
  const setSearch = (value: string) =>
146
- navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
147
 
148
  const setExamType = (value: string) =>
149
- navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
150
 
151
  const setSection = (value: string) =>
152
- navigate({ search: (prev) => ({ ...prev, section: value, page: 1 }) });
153
 
154
  const setFormat = (value: string) =>
155
- navigate({ search: (prev) => ({ ...prev, format: value, page: 1 }) });
156
 
157
  const setDifficulty = (value: number | undefined) =>
158
- navigate({ search: (prev) => ({ ...prev, difficulty: value, page: 1 }) });
159
-
160
- const setPage = (newPage: number) =>
161
- navigate({ search: (prev) => ({ ...prev, page: newPage }) });
162
 
163
  const setTab = (newTab: QuestionTab) =>
164
  navigate({
@@ -170,7 +204,6 @@ function BankComponent() {
170
  section: "",
171
  format: "",
172
  difficulty: undefined,
173
- page: 1,
174
  },
175
  });
176
 
@@ -183,7 +216,6 @@ function BankComponent() {
183
  section: "",
184
  format: "",
185
  difficulty: undefined,
186
- page: 1,
187
  }),
188
  });
189
 
@@ -199,6 +231,8 @@ function BankComponent() {
199
  ];
200
 
201
  // ── Bundle helpers ──
 
 
202
  const isQuestionInBundle = (qid: string) =>
203
  bundleQuestions.some((q) => q.id === qid);
204
 
@@ -206,6 +240,10 @@ function BankComponent() {
206
  bundleSections.some((s) => s.id === sid);
207
 
208
  const toggleQuestion = (q: any) => {
 
 
 
 
209
  setBundleQuestions((prev) => {
210
  const exists = prev.find((x) => x.id === q.id);
211
  if (exists) return prev.filter((x) => x.id !== q.id);
@@ -285,7 +323,7 @@ function BankComponent() {
285
  };
286
 
287
  // ── Auto Bundle ──
288
- const autoBundleExamType = examType || null;
289
  const autoBundleSectionType = section || null;
290
 
291
  const onAutoBundle = async (data: {
@@ -328,9 +366,7 @@ function BankComponent() {
328
  };
329
 
330
  // ── Render helpers ──
331
- const questions = questionQuery.data?.questions ?? [];
332
- const totalQuestions = questionQuery.data?.total ?? 0;
333
- const totalPages = Math.ceil(totalQuestions / limit);
334
 
335
  const sections = sectionQuery.data?.sections ?? [];
336
  const groupedSections = sections.reduce((groups: Record<string, any[]>, s: any) => {
@@ -356,6 +392,8 @@ function BankComponent() {
356
  activeChips={activeChips}
357
  hasFilters={hasFilters}
358
  isAdvancedOpen={isAdvancedOpen}
 
 
359
  onToggleAdvanced={() => setIsAdvancedOpen((v) => !v)}
360
  onSetMode={setMode}
361
  onSetTab={setTab}
@@ -383,18 +421,32 @@ function BankComponent() {
383
  <p className="text-lg text-[var(--warm-charcoal)] mt-2">
384
  Pilih soal atau section untuk dibuatkan paket latihan.
385
  </p>
 
 
 
 
 
 
 
 
 
 
386
  </section>
387
 
388
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
389
- <div className="lg:col-span-8">
390
  {mode === "soal" ? (
391
  <SoalBrowser
392
  isLoading={questionQuery.isLoading}
393
  questions={questions}
394
- totalPages={totalPages}
395
- page={page}
 
396
  hasFilters={hasFilters}
397
  userId={userId}
 
 
 
398
  isQuestionInBundle={isQuestionInBundle}
399
  onToggleQuestion={toggleQuestion}
400
  onOpenDetail={setSelectedQuestion}
@@ -402,8 +454,8 @@ function BankComponent() {
402
  onDelete={(id) => {
403
  if (confirm("Yakin mau hapus soal ini?")) deleteQuestion.mutate({ id });
404
  }}
405
- onSetPage={setPage}
406
  onClearFilters={clearFilters}
 
407
  />
408
  ) : (
409
  <SectionBrowser
@@ -424,6 +476,7 @@ function BankComponent() {
424
  bundleIsPublic={bundleIsPublic}
425
  isCreating={isCreating}
426
  autoBundleExamType={autoBundleExamType}
 
427
  onSetTitle={setBundleTitle}
428
  onSetDescription={setBundleDescription}
429
  onSetIsPublic={setBundleIsPublic}
@@ -468,6 +521,36 @@ function BankComponent() {
468
  isPending={isPackagePending}
469
  />
470
  )}
 
 
 
 
 
 
 
471
  </div>
472
  );
473
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect } from "@tanstack/react-router";
4
  import { z } from "zod";
 
10
  import { useLocalStorageBoolean } from "@/hooks/use-local-storage-boolean";
11
  import { AutoBundleModal } from "@/components/bank/AutoBundleModal";
12
  import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
13
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
14
+ import { PageTour, TourHelpButton } from "@/components/TourGuide";
15
  import { FilterBar } from "@/components/bank/FilterBar";
16
  import { MobileFilterSheet } from "@/components/bank/MobileFilterSheet";
17
  import { AdvancedFilters } from "@/components/bank/AdvancedFilters";
 
19
  import { SectionBrowser } from "@/components/bank/SectionBrowser";
20
  import { BundleSidebar } from "@/components/bank/BundleSidebar";
21
  import { toast } from "sonner";
22
+ import type { Step } from "react-joyride";
23
 
24
  const FILTER_ADVANCED_KEY = "labas-bank-filter-advanced";
25
 
 
33
  section: z.string().optional(),
34
  format: z.string().optional(),
35
  difficulty: z.coerce.number().optional(),
 
36
  }).parse,
37
  beforeLoad: async () => {
38
  const session = await authClient.getSession();
 
53
  const userId = session?.user.id;
54
 
55
  const mode: Mode = search.mode ?? "soal";
56
+ const tab: QuestionTab = search.tab ?? "public";
57
  const searchText = search.search ?? "";
58
  const examType = search.examType ?? "";
59
  const section = search.section ?? "";
60
  const format = search.format ?? "";
61
  const difficulty = search.difficulty;
62
+
63
+ // ── Infinite scroll state ──
64
+ const [allQuestions, setAllQuestions] = useState<any[]>([]);
65
+ const [offset, setOffset] = useState(0);
66
  const limit = 12;
67
+ const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode });
68
 
69
  // ── Sidebar / Bundle State ──
70
  const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
 
94
  ? { creatorUserId: userId }
95
  : { isPublic: true }),
96
  limit,
97
+ offset,
98
  },
99
  { enabled: mode === "soal" },
100
  ),
101
  );
102
 
103
+ // Reset offset when filters change
104
+ useEffect(() => {
105
+ setOffset(0);
106
+ }, [filterKey]);
107
+
108
+ // Append / replace questions when query data arrives
109
+ useEffect(() => {
110
+ const data = questionQuery.data;
111
+ if (!data) return;
112
+ if (offset === 0) {
113
+ setAllQuestions(data.questions ?? []);
114
+ } else {
115
+ setAllQuestions((prev) => {
116
+ const existingIds = new Set(prev.map((q: any) => q.id));
117
+ const newQs = (data.questions ?? []).filter((q: any) => !existingIds.has(q.id));
118
+ return [...prev, ...newQs];
119
+ });
120
+ }
121
+ }, [questionQuery.data]);
122
+
123
+ const totalQuestions = questionQuery.data?.total ?? 0;
124
+ const hasMore = offset + limit < totalQuestions;
125
+
126
  const sectionQuery = useQuery(
127
  trpc.combo.availableSections.queryOptions(
128
  {
 
153
  onSuccess: () => questionQuery.refetch(),
154
  });
155
 
156
+ const bulkPublish = useMutation({
157
+ ...trpc.question.bulkPublish.mutationOptions(),
158
+ onSuccess: () => {
159
+ questionQuery.refetch();
160
+ toast.success("Soal berhasil dipublikasikan");
161
+ },
162
+ onError: (err: any) => toast.error("Gagal mempublikasikan", { description: err.message }),
163
+ });
164
+
165
  // ── Navigation helpers ──
166
  const setMode = (newMode: Mode) => {
167
  navigate({
 
173
  section: "",
174
  format: "",
175
  difficulty: undefined,
 
176
  },
177
  });
178
  if (newMode === "soal") setBundleSections([]);
 
180
  };
181
 
182
  const setSearch = (value: string) =>
183
+ navigate({ search: (prev) => ({ ...prev, search: value }) });
184
 
185
  const setExamType = (value: string) =>
186
+ navigate({ search: (prev) => ({ ...prev, examType: value }) });
187
 
188
  const setSection = (value: string) =>
189
+ navigate({ search: (prev) => ({ ...prev, section: value }) });
190
 
191
  const setFormat = (value: string) =>
192
+ navigate({ search: (prev) => ({ ...prev, format: value }) });
193
 
194
  const setDifficulty = (value: number | undefined) =>
195
+ navigate({ search: (prev) => ({ ...prev, difficulty: value }) });
 
 
 
196
 
197
  const setTab = (newTab: QuestionTab) =>
198
  navigate({
 
204
  section: "",
205
  format: "",
206
  difficulty: undefined,
 
207
  },
208
  });
209
 
 
216
  section: "",
217
  format: "",
218
  difficulty: undefined,
 
219
  }),
220
  });
221
 
 
231
  ];
232
 
233
  // ── Bundle helpers ──
234
+ const lockedExamType = bundleQuestions.length > 0 ? bundleQuestions[0]?.examTypeId : null;
235
+
236
  const isQuestionInBundle = (qid: string) =>
237
  bundleQuestions.some((q) => q.id === qid);
238
 
 
240
  bundleSections.some((s) => s.id === sid);
241
 
242
  const toggleQuestion = (q: any) => {
243
+ if (lockedExamType && q.examTypeId !== lockedExamType) {
244
+ toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
245
+ return;
246
+ }
247
  setBundleQuestions((prev) => {
248
  const exists = prev.find((x) => x.id === q.id);
249
  if (exists) return prev.filter((x) => x.id !== q.id);
 
323
  };
324
 
325
  // ── Auto Bundle ──
326
+ const autoBundleExamType = examType || lockedExamType || null;
327
  const autoBundleSectionType = section || null;
328
 
329
  const onAutoBundle = async (data: {
 
366
  };
367
 
368
  // ── Render helpers ──
369
+ const questions = allQuestions;
 
 
370
 
371
  const sections = sectionQuery.data?.sections ?? [];
372
  const groupedSections = sections.reduce((groups: Record<string, any[]>, s: any) => {
 
392
  activeChips={activeChips}
393
  hasFilters={hasFilters}
394
  isAdvancedOpen={isAdvancedOpen}
395
+ lockedExamType={lockedExamType}
396
+ dataTour="bank-filters"
397
  onToggleAdvanced={() => setIsAdvancedOpen((v) => !v)}
398
  onSetMode={setMode}
399
  onSetTab={setTab}
 
421
  <p className="text-lg text-[var(--warm-charcoal)] mt-2">
422
  Pilih soal atau section untuk dibuatkan paket latihan.
423
  </p>
424
+ <div className="mt-3 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
425
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
426
+ <MaterialIcon name="auto_awesome" className="text-sm" />
427
+ Auto Bundle — biarkan AI pilihkan soal otomatis
428
+ </span>
429
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
430
+ <MaterialIcon name="touch_app" className="text-sm" />
431
+ Manual — pilih sendiri soal satu per satu
432
+ </span>
433
+ </div>
434
  </section>
435
 
436
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
437
+ <div data-tour="bank-questions" className="lg:col-span-8">
438
  {mode === "soal" ? (
439
  <SoalBrowser
440
  isLoading={questionQuery.isLoading}
441
  questions={questions}
442
+ hasMore={hasMore}
443
+ isFetchingNextPage={questionQuery.isFetching}
444
+ onLoadMore={() => setOffset((prev) => prev + limit)}
445
  hasFilters={hasFilters}
446
  userId={userId}
447
+ lockedExamType={lockedExamType}
448
+ tab={tab}
449
+ filterKey={filterKey}
450
  isQuestionInBundle={isQuestionInBundle}
451
  onToggleQuestion={toggleQuestion}
452
  onOpenDetail={setSelectedQuestion}
 
454
  onDelete={(id) => {
455
  if (confirm("Yakin mau hapus soal ini?")) deleteQuestion.mutate({ id });
456
  }}
 
457
  onClearFilters={clearFilters}
458
+ onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
459
  />
460
  ) : (
461
  <SectionBrowser
 
476
  bundleIsPublic={bundleIsPublic}
477
  isCreating={isCreating}
478
  autoBundleExamType={autoBundleExamType}
479
+ lockedExamType={lockedExamType}
480
  onSetTitle={setBundleTitle}
481
  onSetDescription={setBundleDescription}
482
  onSetIsPublic={setBundleIsPublic}
 
521
  isPending={isPackagePending}
522
  />
523
  )}
524
+
525
+ <PageTour
526
+ storageKey={BANK_TOUR_KEY}
527
+ autoDelay={600}
528
+ steps={bankPageSteps}
529
+ />
530
+ <TourHelpButton storageKey={BANK_TOUR_KEY} />
531
  </div>
532
  );
533
  }
534
+
535
+ // ── Bank page tour ──
536
+ const BANK_TOUR_KEY = "labas-page-tour-bank";
537
+ const bankPageSteps: Step[] = [
538
+ {
539
+ target: "[data-tour='bank-filters']",
540
+ title: "Filter & Mode",
541
+ content: "Pilih mode 'Dari Soal' untuk pilih soal satu per satu, atau 'Dari Section' untuk gabung section dari paket yang sudah ada. Filter juga berdasarkan exam type dan kata kunci.",
542
+ spotlightPadding: 8,
543
+ },
544
+ {
545
+ target: "[data-tour='bank-questions']",
546
+ title: "Daftar Soal",
547
+ content: "Semua soal yang sesuai filter ditampilkan di sini. Klik soal untuk melihat detail, atau centang untuk menambahkannya ke paket.",
548
+ spotlightPadding: 8,
549
+ },
550
+ {
551
+ target: "[data-tour='bank-sidebar']",
552
+ title: "Sidebar Paket",
553
+ content: "Soal yang dipilih muncul di sini. Atur judul, deskripsi, dan visibilitas paket. Klik 'Auto Bundle' untuk isi otomatis, atau 'Buat Paket' untuk simpan.",
554
+ spotlightPadding: 8,
555
+ },
556
+ ];
apps/web/src/routes/generate.tsx CHANGED
@@ -16,6 +16,7 @@ import {
16
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
17
  import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
18
  import { ResultSection } from "@/components/generate/ResultSection";
 
19
  import {
20
  EXAM_TYPES,
21
  SECTIONS,
@@ -25,6 +26,7 @@ import {
25
  QUESTION_COUNT_PRESETS,
26
  } from "@/lib/generate-constants";
27
  import "flag-icons/css/flag-icons.min.css";
 
28
 
29
  const MAX_PARALLEL = 3;
30
 
@@ -199,8 +201,26 @@ function RouteComponent() {
199
  AI Exam Generator
200
  </h1>
201
  <p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
202
- Generate soal latihan dengan AI. Pilih section, format, dan topik — sisanya AI yang kerjakan.
203
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  </section>
205
 
206
  {!hasConfigs && (
@@ -251,7 +271,7 @@ function RouteComponent() {
251
  <div className="lg:col-span-8 flex flex-col gap-10">
252
 
253
  {/* Exam Type */}
254
- <div className="flex flex-col gap-4">
255
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
256
  <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
257
  {EXAM_TYPES.map((t) => (
@@ -272,7 +292,7 @@ function RouteComponent() {
272
  </div>
273
 
274
  {/* Section Selection — Multi-select */}
275
- <div className="flex flex-col gap-4">
276
  <div className="flex items-center justify-between">
277
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
278
  <span className="text-xs text-[var(--warm-charcoal)]">
@@ -310,7 +330,7 @@ function RouteComponent() {
310
  </div>
311
 
312
  {/* Question Count */}
313
- <div className="flex flex-col gap-4">
314
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">
315
  Jumlah Soal
316
  <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
@@ -373,7 +393,7 @@ function RouteComponent() {
373
  </div>
374
 
375
  {/* Difficulty */}
376
- <div className="flex flex-col gap-4">
377
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
378
  <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
379
  {DIFFICULTIES.map((d, i) => (
@@ -393,7 +413,7 @@ function RouteComponent() {
393
  </div>
394
 
395
  {/* Format Selection */}
396
- <div className="flex flex-col gap-4">
397
  <div className="flex items-center justify-between">
398
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
399
  <span className="text-xs text-[var(--warm-charcoal)]">
@@ -421,7 +441,7 @@ function RouteComponent() {
421
  </div>
422
 
423
  {/* Topic Focus */}
424
- <div className="flex flex-col gap-4">
425
  <div className="flex items-center justify-between">
426
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
427
  <span className="text-xs text-[var(--warm-charcoal)]">
@@ -451,7 +471,7 @@ function RouteComponent() {
451
  </div>
452
 
453
  {/* Weakness Alignment */}
454
- <div className="flex flex-col gap-4">
455
  <div className="flex justify-between items-end">
456
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
457
  <span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
@@ -476,22 +496,24 @@ function RouteComponent() {
476
  </div>
477
 
478
  {/* Live Preview Card */}
479
- <TestBlueprintCard
480
- examType={examType}
481
- selectedSections={selectedSections}
482
- selectedFormats={selectedFormats}
483
- questionCount={questionCount}
484
- weaknessAlign={weaknessAlign}
485
- mode={mode}
486
- setMode={setMode}
487
- activeCount={activeCount}
488
- maxParallel={MAX_PARALLEL}
489
- generatePending={generate.isPending}
490
- hasKey={hasConfigs}
491
- error={error}
492
- onGenerate={handleGenerate}
493
- onDismissError={() => setError(null)}
494
- />
 
 
495
  </div>
496
 
497
  {/* Results with Tabs */}
@@ -562,6 +584,66 @@ function RouteComponent() {
562
  </div>
563
  )}
564
  </div>
 
 
 
 
 
 
 
565
  </div>
566
  );
567
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
17
  import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
18
  import { ResultSection } from "@/components/generate/ResultSection";
19
+ import { PageTour, TourHelpButton } from "@/components/TourGuide";
20
  import {
21
  EXAM_TYPES,
22
  SECTIONS,
 
26
  QUESTION_COUNT_PRESETS,
27
  } from "@/lib/generate-constants";
28
  import "flag-icons/css/flag-icons.min.css";
29
+ import type { Step } from "react-joyride";
30
 
31
  const MAX_PARALLEL = 3;
32
 
 
201
  AI Exam Generator
202
  </h1>
203
  <p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
204
+ Generate soal latihan dengan AI. Pilih exam, section, format, dan topik — sisanya AI yang kerjakan.
205
  </p>
206
+ <div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
207
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
208
+ <MaterialIcon name="looks_one" className="text-sm" />
209
+ Pilih exam &amp; section
210
+ </span>
211
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
212
+ <MaterialIcon name="looks_two" className="text-sm" />
213
+ Atur jumlah &amp; format
214
+ </span>
215
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] font-medium">
216
+ <MaterialIcon name="looks_3" className="text-sm" />
217
+ Generate &amp; simpan
218
+ </span>
219
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--clay-black)]/10 text-[var(--clay-black)] font-medium">
220
+ <MaterialIcon name="looks_4" className="text-sm" />
221
+ Buat paket dari Bank Soal
222
+ </span>
223
+ </div>
224
  </section>
225
 
226
  {!hasConfigs && (
 
271
  <div className="lg:col-span-8 flex flex-col gap-10">
272
 
273
  {/* Exam Type */}
274
+ <div data-tour="generate-exam-type" className="flex flex-col gap-4">
275
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
276
  <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
277
  {EXAM_TYPES.map((t) => (
 
292
  </div>
293
 
294
  {/* Section Selection — Multi-select */}
295
+ <div data-tour="generate-section" className="flex flex-col gap-4">
296
  <div className="flex items-center justify-between">
297
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
298
  <span className="text-xs text-[var(--warm-charcoal)]">
 
330
  </div>
331
 
332
  {/* Question Count */}
333
+ <div data-tour="generate-count" className="flex flex-col gap-4">
334
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">
335
  Jumlah Soal
336
  <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
 
393
  </div>
394
 
395
  {/* Difficulty */}
396
+ <div data-tour="generate-difficulty" className="flex flex-col gap-4">
397
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
398
  <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
399
  {DIFFICULTIES.map((d, i) => (
 
413
  </div>
414
 
415
  {/* Format Selection */}
416
+ <div data-tour="generate-format" className="flex flex-col gap-4">
417
  <div className="flex items-center justify-between">
418
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
419
  <span className="text-xs text-[var(--warm-charcoal)]">
 
441
  </div>
442
 
443
  {/* Topic Focus */}
444
+ <div data-tour="generate-topic" className="flex flex-col gap-4">
445
  <div className="flex items-center justify-between">
446
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
447
  <span className="text-xs text-[var(--warm-charcoal)]">
 
471
  </div>
472
 
473
  {/* Weakness Alignment */}
474
+ <div data-tour="generate-weakness" className="flex flex-col gap-4">
475
  <div className="flex justify-between items-end">
476
  <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
477
  <span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
 
496
  </div>
497
 
498
  {/* Live Preview Card */}
499
+ <div data-tour="generate-blueprint" className="lg:col-span-4">
500
+ <TestBlueprintCard
501
+ examType={examType}
502
+ selectedSections={selectedSections}
503
+ selectedFormats={selectedFormats}
504
+ questionCount={questionCount}
505
+ weaknessAlign={weaknessAlign}
506
+ mode={mode}
507
+ setMode={setMode}
508
+ activeCount={activeCount}
509
+ maxParallel={MAX_PARALLEL}
510
+ generatePending={generate.isPending}
511
+ hasKey={hasConfigs}
512
+ error={error}
513
+ onGenerate={handleGenerate}
514
+ onDismissError={() => setError(null)}
515
+ />
516
+ </div>
517
  </div>
518
 
519
  {/* Results with Tabs */}
 
584
  </div>
585
  )}
586
  </div>
587
+
588
+ <PageTour
589
+ storageKey={GENERATE_TOUR_KEY}
590
+ autoDelay={600}
591
+ steps={generatePageSteps}
592
+ />
593
+ <TourHelpButton storageKey={GENERATE_TOUR_KEY} />
594
  </div>
595
  );
596
  }
597
+
598
+ // ── Generate page tour ──
599
+ const GENERATE_TOUR_KEY = "labas-page-tour-generate";
600
+ const generatePageSteps: Step[] = [
601
+ {
602
+ target: "[data-tour='generate-exam-type']",
603
+ title: "Jenis Ujian",
604
+ content: "Pilih jenis ujian yang ingin kamu latih. Tersedia IELTS, TOEFL, JLPT, HSK, dan Goethe.",
605
+ spotlightPadding: 8,
606
+ },
607
+ {
608
+ target: "[data-tour='generate-section']",
609
+ title: "Section",
610
+ content: "Pilih section yang ingin digenerate. Bisa pilih lebih dari satu. Mode Agentic dengan ≥20 soal otomatis membagi soal ke setiap section.",
611
+ spotlightPadding: 8,
612
+ },
613
+ {
614
+ target: "[data-tour='generate-count']",
615
+ title: "Jumlah Soal",
616
+ content: "Atur jumlah soal yang ingin digenerate via preset atau slider. Maksimal 40 soal per generate.",
617
+ spotlightPadding: 8,
618
+ },
619
+ {
620
+ target: "[data-tour='generate-difficulty']",
621
+ title: "Tingkat Kesulitan",
622
+ content: "Pilih tingkat kesulitan: Beginner, Intermediate, Academic, atau Expert.",
623
+ spotlightPadding: 8,
624
+ },
625
+ {
626
+ target: "[data-tour='generate-format']",
627
+ title: "Format Soal",
628
+ content: "Pilih format soal (multiple choice, true/false, dll). Format tersedia tergantung exam type yang dipilih.",
629
+ spotlightPadding: 8,
630
+ },
631
+ {
632
+ target: "[data-tour='generate-topic']",
633
+ title: "Topik",
634
+ content: "Pilih topik yang ingin difokuskan. Bisa pilih lebih dari satu topik.",
635
+ spotlightPadding: 8,
636
+ },
637
+ {
638
+ target: "[data-tour='generate-weakness']",
639
+ title: "Intelligent Focus",
640
+ content: "Atur fokus pada kelemahan kamu. AI akan menarget area yang perlu ditingkatkan berdasarkan riwayat jawaban.",
641
+ spotlightPadding: 8,
642
+ },
643
+ {
644
+ target: "[data-tour='generate-blueprint']",
645
+ title: "Test Blueprint & Generate",
646
+ content: "Ringkasan konfigurasi kamu. Pilih mode Quick (cepat) atau Agentic (multi-tahap). Klik 'Generate & Launch' untuk memulai!",
647
+ spotlightPadding: 8,
648
+ },
649
+ ];
apps/web/src/routes/index.tsx CHANGED
@@ -5,7 +5,6 @@ import { trpc } from "@/utils/trpc";
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
7
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
- import { formatTime } from "@/lib/time";
9
 
10
  export const Route = createFileRoute("/")({
11
  component: HomeComponent,
@@ -33,32 +32,94 @@ function HomeComponent() {
33
  const recentAttempts = useQuery(
34
  trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
35
  );
 
 
 
36
 
37
  const stats = overview.data;
38
  const attempts = recentAttempts.data?.attempts ?? [];
 
 
 
39
 
40
  return (
41
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-6xl mx-auto bg-[var(--warm-cream)]">
42
- <section className="mb-10">
 
43
  <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
44
- Labas
45
  </h1>
46
- <p className="text-lg text-[var(--warm-charcoal)] mt-2">
47
- Halo, {session.data?.user.name ?? "Pengguna"}! Platform latihan ujian bahasa dengan AI.
48
  </p>
49
  </section>
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  {/* Quick Stats */}
52
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
53
  <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
54
- <CardContent className="p-6">
55
- <div className="flex items-center gap-4">
56
- <div className="h-12 w-12 bg-[var(--matcha-300)] rounded-[var(--radius-lg)] flex items-center justify-center">
57
- <MaterialIcon name="assignment" className="text-xl text-[var(--matcha-800)]" />
58
  </div>
59
  <div>
60
  <p className="text-sm text-[var(--warm-charcoal)]">Latihan Selesai</p>
61
- <p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
62
  {stats?.completedAttempts ?? 0}
63
  </p>
64
  </div>
@@ -66,29 +127,44 @@ function HomeComponent() {
66
  </CardContent>
67
  </Card>
68
  <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
69
- <CardContent className="p-6">
70
- <div className="flex items-center gap-4">
71
- <div className="h-12 w-12 bg-[var(--slushie-500)] rounded-[var(--radius-lg)] flex items-center justify-center">
72
- <MaterialIcon name="timer" className="text-xl text-[var(--clay-black)]" />
73
  </div>
74
  <div>
75
  <p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
76
- <p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
77
- {stats ? `${Math.round(stats.totalTimeSpentSec / 60)} menit` : "0 menit"}
78
  </p>
79
  </div>
80
  </div>
81
  </CardContent>
82
  </Card>
83
  <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
84
- <CardContent className="p-6">
85
- <div className="flex items-center gap-4">
86
- <div className="h-12 w-12 bg-[var(--lemon-400)] rounded-[var(--radius-lg)] flex items-center justify-center">
87
- <MaterialIcon name="percent" className="text-xl text-[var(--lemon-800)]" />
88
  </div>
89
  <div>
90
- <p className="text-sm text-[var(--warm-charcoal)]">Akurasi Rata-rata</p>
91
- <p className="text-2xl font-bold text-[var(--clay-black)] font-headline">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  {stats ? `${stats.overallAccuracyPct}%` : "-"}
93
  </p>
94
  </div>
@@ -98,55 +174,97 @@ function HomeComponent() {
98
  </div>
99
 
100
  {/* Quick Actions */}
101
- <div className="flex flex-col md:flex-row gap-4 mb-10">
102
- <Link to="/generate">
103
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 px-6 clay-shadow clay-hover">
104
  <MaterialIcon name="auto_awesome" className="text-xl" />
105
  <span className="ml-2">Generate Soal Baru</span>
106
  </Button>
107
  </Link>
108
- <Link to="/packages">
109
- <Button variant="outline" className="rounded-[var(--radius-lg)] h-11 px-6 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover">
110
  <MaterialIcon name="folder" className="text-xl" />
111
  <span className="ml-2">Mulai Latihan</span>
112
  </Button>
113
  </Link>
114
- <Link to="/analytics">
115
- <Button variant="outline" className="rounded-[var(--radius-lg)] h-11 px-6 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover">
116
  <MaterialIcon name="analytics" className="text-xl" />
117
  <span className="ml-2">Lihat Analitik</span>
118
  </Button>
119
  </Link>
120
  </div>
121
 
122
- {/* Recent Attempts */}
123
- <section className="mb-10">
124
- <div className="flex items-center justify-between mb-4">
125
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
126
- Latihan Terakhir
127
- </h2>
128
- <Link to="/history" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
129
- Lihat Semua
130
- </Link>
131
- </div>
132
-
133
- {attempts.length === 0 ? (
134
- <Card className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
135
- <CardContent className="p-6 text-center">
136
- <MaterialIcon name="history" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
137
- <p className="text-[var(--warm-charcoal)] font-semibold">Belum ada latihan</p>
138
- <p className="text-sm text-[var(--warm-silver)] mt-1">
139
- Pilih paket soal dan mulai latihan pertama Anda.
140
- </p>
141
- <Link to="/packages" className="inline-block mt-4">
142
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
143
- <MaterialIcon name="folder" className="mr-2" />
144
- Lihat Paket
145
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  </Link>
147
- </CardContent>
148
- </Card>
149
- ) : (
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  <div className="space-y-3">
151
  {attempts.map((a) => {
152
  const pct =
@@ -182,7 +300,6 @@ function HomeComponent() {
182
  {formatDateShort(a.startedAt)}
183
  </div>
184
  </div>
185
-
186
  <div className="flex items-center gap-4 shrink-0">
187
  {pct != null && (
188
  <div className="text-right">
@@ -211,59 +328,59 @@ function HomeComponent() {
211
  );
212
  })}
213
  </div>
214
- )}
215
- </section>
216
 
217
- {/* Feature Cards */}
218
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
219
- <Link to="/generate" className="block">
220
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
221
- <CardContent className="p-6">
222
- <div className="flex items-center gap-3 mb-4">
223
- <div className="h-10 w-10 bg-[var(--matcha-300)] rounded-[var(--radius-md)] flex items-center justify-center">
224
- <MaterialIcon name="auto_awesome" className="text-xl text-[var(--matcha-800)]" />
 
 
 
225
  </div>
226
- <h3 className="font-headline text-lg font-bold text-[var(--clay-black)]">AI Generator</h3>
227
- </div>
228
- <p className="text-sm text-[var(--warm-charcoal)]">
229
- Generate soal dengan AI menggunakan API key sendiri. Dua mode: Quick dan Agentic.
230
- </p>
231
- </CardContent>
232
- </Card>
233
- </Link>
234
-
235
- <Link to="/bank" className="block">
236
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
237
- <CardContent className="p-6">
238
- <div className="flex items-center gap-3 mb-4">
239
- <div className="h-10 w-10 bg-[var(--slushie-500)] rounded-[var(--radius-md)] flex items-center justify-center">
240
- <MaterialIcon name="database" className="text-xl text-[var(--clay-black)]" />
241
  </div>
242
- <h3 className="font-headline text-lg font-bold text-[var(--clay-black)]">Bank Soal</h3>
243
- </div>
244
- <p className="text-sm text-[var(--warm-charcoal)]">
245
- Simpan, publikasikan, dan gunakan ulang soal latihan Anda. Buat paket dari soal pilihan.
246
- </p>
247
- </CardContent>
248
- </Card>
249
- </Link>
250
-
251
- <Link to="/analytics" className="block">
252
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
253
- <CardContent className="p-6">
254
- <div className="flex items-center gap-3 mb-4">
255
- <div className="h-10 w-10 bg-[var(--lemon-400)] rounded-[var(--radius-md)] flex items-center justify-center">
256
- <MaterialIcon name="analytics" className="text-xl text-[var(--lemon-800)]" />
257
  </div>
258
- <h3 className="font-headline text-lg font-bold text-[var(--clay-black)]">Analytics</h3>
259
- </div>
260
- <p className="text-sm text-[var(--warm-charcoal)]">
261
- Evaluasi per-section, identifikasi kelemahan, dan pantau tren perkembangan Anda.
262
- </p>
263
- </CardContent>
264
- </Card>
265
- </Link>
266
- </div>
267
  </div>
268
  );
269
  }
 
5
  import { Button } from "@labas/ui/components/button";
6
  import { Card, CardContent } from "@labas/ui/components/card";
7
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
8
 
9
  export const Route = createFileRoute("/")({
10
  component: HomeComponent,
 
32
  const recentAttempts = useQuery(
33
  trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
34
  );
35
+ const featuredPackages = useQuery(
36
+ trpc.package.list.queryOptions({ isPublic: true, limit: 3 }),
37
+ );
38
 
39
  const stats = overview.data;
40
  const attempts = recentAttempts.data?.attempts ?? [];
41
+ const featured = featuredPackages.data?.packages ?? [];
42
+
43
+ const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
44
 
45
  return (
46
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-6xl mx-auto bg-[var(--warm-cream)]">
47
+ {/* Header */}
48
+ <section className="mb-8">
49
  <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
50
+ Halo, {session.data?.user.name ?? "Pengguna"}!
51
  </h1>
52
+ <p className="text-lg text-[var(--warm-charcoal)] mt-1">
53
+ {isNewUser ? "Siap mulai belajar? Pilih langkah di bawah ini." : "Lanjutkan latihanmu!"}
54
  </p>
55
  </section>
56
 
57
+ {/* Quick Start Banner for New Users */}
58
+ {isNewUser && (
59
+ <Card className="mb-8 bg-gradient-to-br from-[var(--matcha-300)]/40 to-[var(--slushie-500)]/30 border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)]">
60
+ <CardContent className="p-6 md:p-8">
61
+ <div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-8">
62
+ <div className="shrink-0">
63
+ <div className="h-16 w-16 bg-[var(--clay-black)] rounded-[var(--radius-2xl)] flex items-center justify-center">
64
+ <MaterialIcon name="rocket_launch" className="text-3xl text-[var(--pure-white)]" />
65
+ </div>
66
+ </div>
67
+ <div className="flex-1">
68
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-1">
69
+ Selamat Datang di Labas
70
+ </h2>
71
+ <p className="text-sm text-[var(--warm-charcoal)] mb-4">
72
+ Platform latihan ujian bahasa berbasis AI. Ikuti 3 langkah mudah untuk memulai:
73
+ </p>
74
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
75
+ <Link to="/generate" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--matcha-400)] transition-all group clay-hover">
76
+ <div className="h-9 w-9 bg-[var(--matcha-300)] rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
77
+ <span className="text-xs font-black text-[var(--matcha-800)]">1</span>
78
+ </div>
79
+ <div className="flex-1 min-w-0">
80
+ <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--matcha-800)]">Generate Soal</p>
81
+ <p className="text-xs text-[var(--warm-charcoal)]">Buat soal dengan AI</p>
82
+ </div>
83
+ <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--matcha-800)]" />
84
+ </Link>
85
+ <Link to="/bank" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--slushie-500)] transition-all group clay-hover">
86
+ <div className="h-9 w-9 bg-[var(--slushie-500)]/30 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
87
+ <span className="text-xs font-black text-[var(--slushie-800)]">2</span>
88
+ </div>
89
+ <div className="flex-1 min-w-0">
90
+ <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--slushie-800)]">Buat Paket</p>
91
+ <p className="text-xs text-[var(--warm-charcoal)]">Kumpulkan soal latihan</p>
92
+ </div>
93
+ <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--slushie-800)]" />
94
+ </Link>
95
+ <Link to="/packages" className="flex items-center gap-3 p-3 rounded-[var(--radius-lg)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)] hover:border-[var(--lemon-600)] transition-all group clay-hover">
96
+ <div className="h-9 w-9 bg-[var(--lemon-400)]/50 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
97
+ <span className="text-xs font-black text-[var(--lemon-800)]">3</span>
98
+ </div>
99
+ <div className="flex-1 min-w-0">
100
+ <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--lemon-800)]">Mulai Latihan</p>
101
+ <p className="text-xs text-[var(--warm-charcoal)]">Kerjakan paket soal</p>
102
+ </div>
103
+ <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--lemon-800)]" />
104
+ </Link>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </CardContent>
109
+ </Card>
110
+ )}
111
+
112
  {/* Quick Stats */}
113
+ <div data-tour="dashboard-stats" className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-10">
114
  <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
115
+ <CardContent className="p-5">
116
+ <div className="flex items-center gap-3">
117
+ <div className="h-11 w-11 bg-[var(--matcha-300)] rounded-[var(--radius-lg)] flex items-center justify-center">
118
+ <MaterialIcon name="assignment" className="text-lg text-[var(--matcha-800)]" />
119
  </div>
120
  <div>
121
  <p className="text-sm text-[var(--warm-charcoal)]">Latihan Selesai</p>
122
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
123
  {stats?.completedAttempts ?? 0}
124
  </p>
125
  </div>
 
127
  </CardContent>
128
  </Card>
129
  <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
130
+ <CardContent className="p-5">
131
+ <div className="flex items-center gap-3">
132
+ <div className="h-11 w-11 bg-[var(--slushie-500)] rounded-[var(--radius-lg)] flex items-center justify-center">
133
+ <MaterialIcon name="timer" className="text-lg text-[var(--clay-black)]" />
134
  </div>
135
  <div>
136
  <p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
137
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
138
+ {stats ? `${Math.round(stats.totalTimeSpentSec / 60)}m` : "0m"}
139
  </p>
140
  </div>
141
  </div>
142
  </CardContent>
143
  </Card>
144
  <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
145
+ <CardContent className="p-5">
146
+ <div className="flex items-center gap-3">
147
+ <div className="h-11 w-11 bg-[var(--lemon-400)] rounded-[var(--radius-lg)] flex items-center justify-center">
148
+ <MaterialIcon name="quiz" className="text-lg text-[var(--lemon-800)]" />
149
  </div>
150
  <div>
151
+ <p className="text-sm text-[var(--warm-charcoal)]">Soal Terjawab</p>
152
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
153
+ {stats?.totalQuestionsAnswered ?? 0}
154
+ </p>
155
+ </div>
156
+ </div>
157
+ </CardContent>
158
+ </Card>
159
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
160
+ <CardContent className="p-5">
161
+ <div className="flex items-center gap-3">
162
+ <div className="h-11 w-11 bg-[var(--pomegranate-400)]/30 rounded-[var(--radius-lg)] flex items-center justify-center">
163
+ <MaterialIcon name="trending_up" className="text-lg text-[var(--pomegranate-600)]" />
164
+ </div>
165
+ <div>
166
+ <p className="text-sm text-[var(--warm-charcoal)]">Akurasi</p>
167
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
168
  {stats ? `${stats.overallAccuracyPct}%` : "-"}
169
  </p>
170
  </div>
 
174
  </div>
175
 
176
  {/* Quick Actions */}
177
+ <div className="flex flex-col sm:flex-row gap-3 mb-10">
178
+ <Link to="/generate" className="flex-1">
179
+ <Button className="w-full bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-12 clay-shadow clay-hover text-base">
180
  <MaterialIcon name="auto_awesome" className="text-xl" />
181
  <span className="ml-2">Generate Soal Baru</span>
182
  </Button>
183
  </Link>
184
+ <Link to="/packages" className="flex-1">
185
+ <Button variant="outline" className="w-full rounded-[var(--radius-lg)] h-12 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover text-base">
186
  <MaterialIcon name="folder" className="text-xl" />
187
  <span className="ml-2">Mulai Latihan</span>
188
  </Button>
189
  </Link>
190
+ <Link to="/analytics" className="flex-1">
191
+ <Button variant="outline" className="w-full rounded-[var(--radius-lg)] h-12 border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] clay-hover text-base">
192
  <MaterialIcon name="analytics" className="text-xl" />
193
  <span className="ml-2">Lihat Analitik</span>
194
  </Button>
195
  </Link>
196
  </div>
197
 
198
+ {/* Featured Packages */}
199
+ {featured.length > 0 && (
200
+ <section className="mb-10">
201
+ <div className="flex items-center justify-between mb-4">
202
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
203
+ Paket Unggulan
204
+ </h2>
205
+ <Link to="/packages" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
206
+ Lihat Semua
207
+ </Link>
208
+ </div>
209
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
210
+ {featured.map((pkg) => (
211
+ <Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
212
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full transition-all hover:border-[var(--matcha-400)]">
213
+ <CardContent className="p-5">
214
+ <div className="flex items-start justify-between gap-2 mb-3">
215
+ <span className="px-2 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold shrink-0">
216
+ {pkg.examTypeName}
217
+ </span>
218
+ <span className="text-xs text-[var(--warm-silver)] whitespace-nowrap">
219
+ {pkg.usageCount}x digunakan
220
+ </span>
221
+ </div>
222
+ <h3 className="font-headline text-base font-bold text-[var(--clay-black)] mb-1 line-clamp-2">
223
+ {pkg.title}
224
+ </h3>
225
+ {pkg.description && (
226
+ <p className="text-xs text-[var(--warm-charcoal)] line-clamp-2 mb-3">
227
+ {pkg.description}
228
+ </p>
229
+ )}
230
+ <div className="flex items-center justify-between pt-3 border-t border-[var(--oat-border)]">
231
+ <div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
232
+ <span className="flex items-center gap-1">
233
+ <MaterialIcon name="quiz" className="text-xs" />
234
+ {pkg.totalQuestions}
235
+ </span>
236
+ <span className="flex items-center gap-1">
237
+ <MaterialIcon name="folder" className="text-xs" />
238
+ {pkg.totalSections}
239
+ </span>
240
+ {pkg.estimatedDurationMin && (
241
+ <span className="flex items-center gap-1">
242
+ <MaterialIcon name="timer" className="text-xs" />
243
+ {pkg.estimatedDurationMin}m
244
+ </span>
245
+ )}
246
+ </div>
247
+ <MaterialIcon name="play_circle" className="text-xl text-[var(--matcha-600)]" />
248
+ </div>
249
+ </CardContent>
250
+ </Card>
251
  </Link>
252
+ ))}
253
+ </div>
254
+ </section>
255
+ )}
256
+
257
+ {/* Recent Attempts */}
258
+ {attempts.length > 0 && (
259
+ <section className="mb-10">
260
+ <div className="flex items-center justify-between mb-4">
261
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
262
+ Latihan Terakhir
263
+ </h2>
264
+ <Link to="/history" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
265
+ Lihat Semua
266
+ </Link>
267
+ </div>
268
  <div className="space-y-3">
269
  {attempts.map((a) => {
270
  const pct =
 
300
  {formatDateShort(a.startedAt)}
301
  </div>
302
  </div>
 
303
  <div className="flex items-center gap-4 shrink-0">
304
  {pct != null && (
305
  <div className="text-right">
 
328
  );
329
  })}
330
  </div>
331
+ </section>
332
+ )}
333
 
334
+ {/* Feature Cards - simplified, only show if new user */}
335
+ {isNewUser && (
336
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
337
+ <Link to="/bank" className="block">
338
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
339
+ <CardContent className="p-5">
340
+ <div className="flex items-center gap-3 mb-3">
341
+ <div className="h-9 w-9 bg-[var(--slushie-500)] rounded-[var(--radius-md)] flex items-center justify-center">
342
+ <MaterialIcon name="database" className="text-lg text-[var(--clay-black)]" />
343
+ </div>
344
+ <h3 className="font-headline font-bold text-[var(--clay-black)]">Bank Soal</h3>
345
  </div>
346
+ <p className="text-xs text-[var(--warm-charcoal)]">
347
+ Simpan, publikasikan, dan buat paket dari soal pilihan.
348
+ </p>
349
+ </CardContent>
350
+ </Card>
351
+ </Link>
352
+ <Link to="/history" className="block">
353
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
354
+ <CardContent className="p-5">
355
+ <div className="flex items-center gap-3 mb-3">
356
+ <div className="h-9 w-9 bg-[var(--lemon-400)] rounded-[var(--radius-md)] flex items-center justify-center">
357
+ <MaterialIcon name="history" className="text-lg text-[var(--lemon-800)]" />
358
+ </div>
359
+ <h3 className="font-headline font-bold text-[var(--clay-black)]">Riwayat</h3>
 
360
  </div>
361
+ <p className="text-xs text-[var(--warm-charcoal)]">
362
+ Lihat kembali hasil dan progres latihan kamu.
363
+ </p>
364
+ </CardContent>
365
+ </Card>
366
+ </Link>
367
+ <Link to="/settings" className="block">
368
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
369
+ <CardContent className="p-5">
370
+ <div className="flex items-center gap-3 mb-3">
371
+ <div className="h-9 w-9 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
372
+ <MaterialIcon name="settings" className="text-lg text-[var(--warm-charcoal)]" />
373
+ </div>
374
+ <h3 className="font-headline font-bold text-[var(--clay-black)]">Pengaturan</h3>
 
375
  </div>
376
+ <p className="text-xs text-[var(--warm-charcoal)]">
377
+ Kelola API key dan preferensi akun kamu.
378
+ </p>
379
+ </CardContent>
380
+ </Card>
381
+ </Link>
382
+ </div>
383
+ )}
 
384
  </div>
385
  );
386
  }
apps/web/src/routes/packages.tsx CHANGED
@@ -15,6 +15,9 @@ import {
15
  SelectValue,
16
  } from "@labas/ui/components/select";
17
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
 
 
18
  import { toast } from "sonner";
19
 
20
  export const Route = createFileRoute("/packages")({
@@ -117,7 +120,7 @@ function PackagesComponent() {
117
  return (
118
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
119
  <section className="mb-8">
120
- <div className="flex items-center justify-between">
121
  <div>
122
  <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
123
  Paket Soal
@@ -135,6 +138,9 @@ function PackagesComponent() {
135
  </div>
136
  </section>
137
 
 
 
 
138
  {/* Tabs */}
139
  <div className="flex gap-2 mb-6">
140
  <button
@@ -160,7 +166,7 @@ function PackagesComponent() {
160
  </div>
161
 
162
  {/* Filters */}
163
- <div className="flex flex-col md:flex-row gap-3 mb-8">
164
  <div className="relative flex-1 max-w-md">
165
  <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
166
  <Input
@@ -196,18 +202,32 @@ function PackagesComponent() {
196
  ))}
197
  </div>
198
  ) : packages.length === 0 ? (
199
- <div className="text-center py-20">
200
  <MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
201
  <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
202
- <p className="text-sm text-[var(--warm-silver)] mt-1">
203
  {tab === "mine"
204
  ? "Belum ada paket yang Anda buat. Buat paket dari Bank Soal."
205
- : "Buat paket soal pertama Anda"}
206
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  </div>
208
  ) : (
209
  <>
210
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
211
  {packages.map((pkg) => {
212
  const isOwner = pkg.creatorUserId === userId;
213
  return (
@@ -342,6 +362,32 @@ function PackagesComponent() {
342
  )}
343
  </>
344
  )}
 
 
 
345
  </div>
346
  );
347
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  SelectValue,
16
  } from "@labas/ui/components/select";
17
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
18
+ import { GettingStartedCard } from "@/components/GettingStartedCard";
19
+ import { PageTour, TourHelpButton } from "@/components/TourGuide";
20
+ import type { Step } from "react-joyride";
21
  import { toast } from "sonner";
22
 
23
  export const Route = createFileRoute("/packages")({
 
120
  return (
121
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
122
  <section className="mb-8">
123
+ <div data-tour="packages-header" className="flex items-center justify-between">
124
  <div>
125
  <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
126
  Paket Soal
 
138
  </div>
139
  </section>
140
 
141
+ {/* Getting Started Guide */}
142
+ <GettingStartedCard />
143
+
144
  {/* Tabs */}
145
  <div className="flex gap-2 mb-6">
146
  <button
 
166
  </div>
167
 
168
  {/* Filters */}
169
+ <div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
170
  <div className="relative flex-1 max-w-md">
171
  <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
172
  <Input
 
202
  ))}
203
  </div>
204
  ) : packages.length === 0 ? (
205
+ <div className="text-center py-16">
206
  <MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
207
  <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
208
+ <p className="text-sm text-[var(--warm-silver)] mt-1 mb-6">
209
  {tab === "mine"
210
  ? "Belum ada paket yang Anda buat. Buat paket dari Bank Soal."
211
+ : "Belum ada paket publik. Buat paket soal pertama Anda"}
212
  </p>
213
+ <div className="flex items-center justify-center gap-3">
214
+ <Link to="/generate">
215
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
216
+ <MaterialIcon name="auto_awesome" className="mr-2" />
217
+ Generate Soal
218
+ </Button>
219
+ </Link>
220
+ <Link to="/bank">
221
+ <Button variant="outline" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]">
222
+ <MaterialIcon name="add" className="mr-2" />
223
+ Buat Paket
224
+ </Button>
225
+ </Link>
226
+ </div>
227
  </div>
228
  ) : (
229
  <>
230
+ <div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
231
  {packages.map((pkg) => {
232
  const isOwner = pkg.creatorUserId === userId;
233
  return (
 
362
  )}
363
  </>
364
  )}
365
+
366
+ <PageTour storageKey={PACKAGES_TOUR_KEY} autoDelay={600} steps={packagesPageSteps} />
367
+ <TourHelpButton storageKey={PACKAGES_TOUR_KEY} />
368
  </div>
369
  );
370
  }
371
+
372
+ // ── Packages page tour ──
373
+ const PACKAGES_TOUR_KEY = "labas-page-tour-packages";
374
+ const packagesPageSteps: Step[] = [
375
+ {
376
+ target: "[data-tour='packages-header']",
377
+ title: "Paket Soal",
378
+ content: "Temukan paket soal dari komunitas atau lihat paket buatan sendiri. Klik 'Buat Paket' untuk membuat paket baru dari Bank Soal.",
379
+ spotlightPadding: 8,
380
+ },
381
+ {
382
+ target: "[data-tour='packages-filters']",
383
+ title: "Filter & Pencarian",
384
+ content: "Cari paket berdasarkan nama atau filter berdasarkan jenis ujian (IELTS, TOEFL, dll). Bisa juga switch antara 'Semua Paket' dan 'Paket Saya'.",
385
+ spotlightPadding: 8,
386
+ },
387
+ {
388
+ target: "[data-tour='packages-list']",
389
+ title: "Mulai Latihan",
390
+ content: "Klik kartu paket untuk lihat detail, atau langsung klik 'Mulai Latihan' untuk mengerjakan soal. Pantau skor dan progres kamu!",
391
+ spotlightPadding: 8,
392
+ },
393
+ ];
bun.lock CHANGED
@@ -7,6 +7,7 @@
7
  "dependencies": {
8
  "@labas/env": "workspace:*",
9
  "dotenv": "catalog:",
 
10
  "zod": "catalog:",
11
  },
12
  "devDependencies": {
@@ -489,6 +490,8 @@
489
 
490
  "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
491
 
 
 
492
  "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
493
 
494
  "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@@ -497,6 +500,12 @@
497
 
498
  "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
499
 
 
 
 
 
 
 
500
  "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
501
 
502
  "@hono/trpc-server": ["@hono/trpc-server@0.4.2", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.0.0" } }, "sha512-3TDrc42CZLgcTFkXQba+y7JlRWRiyw1AqhLqztWyNS2IFT+3bHld0lxKdGBttCtGKHYx0505dM67RMazjhdZqw=="],
@@ -1293,6 +1302,8 @@
1293
 
1294
  "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
1295
 
 
 
1296
  "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
1297
 
1298
  "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
@@ -1609,8 +1620,12 @@
1609
 
1610
  "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="],
1611
 
 
 
1612
  "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="],
1613
 
 
 
1614
  "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
1615
 
1616
  "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -1689,6 +1704,10 @@
1689
 
1690
  "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
1691
 
 
 
 
 
1692
  "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
1693
 
1694
  "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@@ -1843,7 +1862,7 @@
1843
 
1844
  "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
1845
 
1846
- "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
1847
 
1848
  "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
1849
 
@@ -2077,6 +2096,8 @@
2077
 
2078
  "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
2079
 
 
 
2080
  "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
2081
 
2082
  "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
 
7
  "dependencies": {
8
  "@labas/env": "workspace:*",
9
  "dotenv": "catalog:",
10
+ "react-joyride": "^3.1.0",
11
  "zod": "catalog:",
12
  },
13
  "devDependencies": {
 
490
 
491
  "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
492
 
493
+ "@fastify/deepmerge": ["@fastify/deepmerge@3.2.1", "", {}, "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA=="],
494
+
495
  "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
496
 
497
  "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
 
500
 
501
  "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
502
 
503
+ "@gilbarbara/deep-equal": ["@gilbarbara/deep-equal@0.4.1", "", {}, "sha512-QF2BGeQjsa59T59XvFdR3is5jrl28Eg0J6giXAC5919bcqvR8XP4B+07tpbs6Y6/IQd4FBncaL2WVXIBgSxt4w=="],
504
+
505
+ "@gilbarbara/hooks": ["@gilbarbara/hooks@0.11.0", "", { "dependencies": { "@gilbarbara/deep-equal": "^0.4.1" }, "peerDependencies": { "react": "16.8 - 19" } }, "sha512-CIVazdxqFRplUfm9wZL3/0X1TURJekhPMWGFdWzEmyJrGPiotX2yxA1KiB8N7VnhawIaMtb2Apnda4Y6DRwi2Q=="],
506
+
507
+ "@gilbarbara/types": ["@gilbarbara/types@0.2.2", "", { "dependencies": { "type-fest": "^4.1.0" } }, "sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ=="],
508
+
509
  "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
510
 
511
  "@hono/trpc-server": ["@hono/trpc-server@0.4.2", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.0.0" } }, "sha512-3TDrc42CZLgcTFkXQba+y7JlRWRiyw1AqhLqztWyNS2IFT+3bHld0lxKdGBttCtGKHYx0505dM67RMazjhdZqw=="],
 
1302
 
1303
  "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
1304
 
1305
+ "is-lite": ["is-lite@2.0.0", "", {}, "sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g=="],
1306
+
1307
  "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
1308
 
1309
  "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
 
1620
 
1621
  "react-hook-form": ["react-hook-form@7.73.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA=="],
1622
 
1623
+ "react-innertext": ["react-innertext@1.1.5", "", { "peerDependencies": { "@types/react": ">=0.0.0 <=99", "react": ">=0.0.0 <=99" } }, "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q=="],
1624
+
1625
  "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="],
1626
 
1627
+ "react-joyride": ["react-joyride@3.1.0", "", { "dependencies": { "@fastify/deepmerge": "^3.2.1", "@floating-ui/react-dom": "^2.1.8", "@gilbarbara/deep-equal": "^0.4.1", "@gilbarbara/hooks": "^0.11.0", "@gilbarbara/types": "^0.2.2", "is-lite": "^2.0.0", "react-innertext": "^1.1.5", "scroll": "^3.0.1", "scrollparent": "^2.1.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "16.8 - 19", "react-dom": "16.8 - 19" } }, "sha512-+UEDpNsYSHhhSW/OQcNl6+oODYx20EP6TykSD45if0MqAAZMYD+3DU64w9wP3fBjQswvq5BgK99w3rw6ing69g=="],
1628
+
1629
  "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
1630
 
1631
  "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
 
1704
 
1705
  "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
1706
 
1707
+ "scroll": ["scroll@3.0.1", "", {}, "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg=="],
1708
+
1709
+ "scrollparent": ["scrollparent@2.1.0", "", {}, "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="],
1710
+
1711
  "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
1712
 
1713
  "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
 
1862
 
1863
  "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
1864
 
1865
+ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
1866
 
1867
  "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
1868
 
 
2096
 
2097
  "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
2098
 
2099
+ "msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
2100
+
2101
  "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
2102
 
2103
  "path-scurry/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
package.json CHANGED
@@ -38,6 +38,7 @@
38
  "dependencies": {
39
  "@labas/env": "workspace:*",
40
  "dotenv": "catalog:",
 
41
  "zod": "catalog:"
42
  },
43
  "devDependencies": {
 
38
  "dependencies": {
39
  "@labas/env": "workspace:*",
40
  "dotenv": "catalog:",
41
+ "react-joyride": "^3.1.0",
42
  "zod": "catalog:"
43
  },
44
  "devDependencies": {
packages/ai/src/agentic.ts CHANGED
@@ -193,7 +193,7 @@ Rules:
193
  - Questions should test real comprehension, not surface recall
194
  - For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
195
  - Options must be plausible distractors
196
- - explanation (explanation) - dijelaskan dengan bahasa Indonesia
197
  - For true_false_not_given: correctAnswer must be exactly TRUE, FALSE, or NOT_GIVEN (uppercase)
198
  - For author_view: correctAnswer must be exactly YES, NO, or NOT_GIVEN (uppercase)
199
 
@@ -302,7 +302,7 @@ Rules:
302
  - Each question must be directly answerable from the passage
303
  - Use "passageText" field with relevant excerpt (or full passage)
304
  - For multiple choice: provide 4 options (A, B, C, D)
305
- - explanation - dijelaskan dengan bahasa Indonesia
306
  - For true_false_not_given: correctAnswer must be TRUE, FALSE, or NOT_GIVEN (uppercase)
307
  - For author_view: correctAnswer must be YES, NO, or NOT_GIVEN (uppercase)
308
 
@@ -439,7 +439,27 @@ export async function generateQuestionsAgentic(
439
 
440
  // Repair & parse
441
  let { valid, invalid, repairLog } = repairAndParseQuestions(rawQuestions, passage);
442
- validQuestions = valid;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  allRepairLogs.push(...repairLog);
444
 
445
  // Regenerate structural-invalid questions with bounded attempts
 
193
  - Questions should test real comprehension, not surface recall
194
  - For multiple choice: always provide 4 options (A, B, C, D) with one clearly correct answer
195
  - Options must be plausible distractors
196
+ - explanation WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
197
  - For true_false_not_given: correctAnswer must be exactly TRUE, FALSE, or NOT_GIVEN (uppercase)
198
  - For author_view: correctAnswer must be exactly YES, NO, or NOT_GIVEN (uppercase)
199
 
 
302
  - Each question must be directly answerable from the passage
303
  - Use "passageText" field with relevant excerpt (or full passage)
304
  - For multiple choice: provide 4 options (A, B, C, D)
305
+ - explanation WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing.
306
  - For true_false_not_given: correctAnswer must be TRUE, FALSE, or NOT_GIVEN (uppercase)
307
  - For author_view: correctAnswer must be YES, NO, or NOT_GIVEN (uppercase)
308
 
 
439
 
440
  // Repair & parse
441
  let { valid, invalid, repairLog } = repairAndParseQuestions(rawQuestions, passage);
442
+
443
+ // Move questions with non-Indonesian (CJK) explanations to invalid for regeneration
444
+ const hasCJK = (text: string) => /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(text);
445
+ const cjkInvalid: Array<{ index: number; raw: unknown; errors: string[] }> = [];
446
+ const filteredValid: typeof valid = [];
447
+ for (let vi = 0; vi < valid.length; vi++) {
448
+ const q = valid[vi];
449
+ if (!q) continue;
450
+ if (q.explanation && hasCJK(q.explanation)) {
451
+ cjkInvalid.push({
452
+ index: vi,
453
+ raw: q,
454
+ errors: ["explanation contains CJK characters (should be Bahasa Indonesia)"],
455
+ });
456
+ repairLog.push(`Q${vi + 1}: explanation contains CJK characters, moved to regeneration queue`);
457
+ } else {
458
+ filteredValid.push(q);
459
+ }
460
+ }
461
+ validQuestions = filteredValid;
462
+ invalid = [...invalid, ...cjkInvalid];
463
  allRepairLogs.push(...repairLog);
464
 
465
  // Regenerate structural-invalid questions with bounded attempts
packages/ai/src/prompts.ts CHANGED
@@ -21,9 +21,9 @@ INSTRUCTIONS:
21
  - Passage length should be appropriate for the exam type and difficulty.
22
  - Each question must have:
23
  * a reading passage (passageText)
24
- * a clear question prompt (questionText)
25
- * a correct answer (correctAnswer)
26
- * an explanation (explanation) - dijelaskan dengan bahasa Indonesia
27
  * difficulty level (${difficulty})
28
  * relevant skill tags (skillTags)
29
  - Questions should test real comprehension, not just surface-level recall.
 
21
  - Passage length should be appropriate for the exam type and difficulty.
22
  - Each question must have:
23
  * a reading passage (passageText)
24
+ * a clear question prompt (questionText)
25
+ * a correct answer (correctAnswer)
26
+ * an explanation (explanation) WAJIB ditulis dalam Bahasa Indonesia. DILARANG menggunakan bahasa asing (China, Jepang, Jerman, Inggris) untuk explanation.
27
  * difficulty level (${difficulty})
28
  * relevant skill tags (skillTags)
29
  - Questions should test real comprehension, not just surface-level recall.
packages/ai/src/repair.ts CHANGED
@@ -144,6 +144,10 @@ function ensureSkillTags(q: GenericQuestion): string[] {
144
  return ["comprehension"];
145
  }
146
 
 
 
 
 
147
  function ensureExplanation(q: GenericQuestion): string {
148
  if (q.explanation && q.explanation.trim().length > 0) {
149
  return q.explanation.trim();
@@ -204,9 +208,13 @@ export function repairQuestion(
204
  notes.push("questionText too short, used fallback");
205
  wasRepaired = true;
206
  }
207
- if (!r.explanation || String(r.explanation).trim().length === 0) {
 
208
  notes.push("explanation missing, used fallback");
209
  wasRepaired = true;
 
 
 
210
  }
211
  if (!Array.isArray(r.skillTags) || r.skillTags.length === 0) {
212
  notes.push("skillTags missing, used fallback");
 
144
  return ["comprehension"];
145
  }
146
 
147
+ function hasCJK(text: string): boolean {
148
+ return /[\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]/.test(text);
149
+ }
150
+
151
  function ensureExplanation(q: GenericQuestion): string {
152
  if (q.explanation && q.explanation.trim().length > 0) {
153
  return q.explanation.trim();
 
208
  notes.push("questionText too short, used fallback");
209
  wasRepaired = true;
210
  }
211
+ const explanationText = String(r.explanation ?? "");
212
+ if (explanationText.trim().length === 0) {
213
  notes.push("explanation missing, used fallback");
214
  wasRepaired = true;
215
+ } else if (hasCJK(explanationText)) {
216
+ notes.push("explanation contains CJK characters (should be Bahasa Indonesia), marked for regeneration");
217
+ wasRepaired = true;
218
  }
219
  if (!Array.isArray(r.skillTags) || r.skillTags.length === 0) {
220
  notes.push("skillTags missing, used fallback");
packages/api/src/routers/attempt.ts CHANGED
@@ -46,11 +46,72 @@ function normalizeAnswer(format: string, userAnswer: string, correctAnswer: stri
46
  }
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  export const attemptRouter = router({
50
  start: protectedProcedure
51
  .input(z.object({ packageId: z.string().uuid() }))
52
  .mutation(async ({ ctx, input }) => {
53
  const userId = ctx.session.user.id;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  const [pkg] = await db
56
  .select()
@@ -242,6 +303,7 @@ export const attemptRouter = router({
242
  )
243
  .mutation(async ({ ctx, input }) => {
244
  const userId = ctx.session.user.id;
 
245
 
246
  const [attempt] = await db
247
  .select()
@@ -300,6 +362,7 @@ export const attemptRouter = router({
300
  .input(z.object({ attemptId: z.string().uuid() }))
301
  .mutation(async ({ ctx, input }) => {
302
  const userId = ctx.session.user.id;
 
303
 
304
  const [attempt] = await db
305
  .select()
@@ -311,6 +374,14 @@ export const attemptRouter = router({
311
  if (attempt.userId !== userId) throwForbidden();
312
  if (attempt.status !== "in_progress") throwBadRequest("Attempt already finished");
313
 
 
 
 
 
 
 
 
 
314
  const dbSections = await db
315
  .select()
316
  .from(sectionResult)
 
46
  }
47
  }
48
 
49
+ // ── Simple in-memory rate limiter ──
50
+ const rateLimitMap = new Map<string, number>();
51
+ let rateLimitCleanup: ReturnType<typeof setInterval> | null = null;
52
+
53
+ function startRateLimitCleanup() {
54
+ if (rateLimitCleanup) return;
55
+ rateLimitCleanup = setInterval(() => {
56
+ const cutoff = Date.now() - 60_000;
57
+ for (const [key, time] of rateLimitMap) {
58
+ if (time < cutoff) rateLimitMap.delete(key);
59
+ }
60
+ }, 60_000);
61
+ }
62
+
63
+ function checkRateLimit(key: string, windowMs: number) {
64
+ startRateLimitCleanup();
65
+ const now = Date.now();
66
+ const last = rateLimitMap.get(key) ?? 0;
67
+ if (now - last < windowMs) {
68
+ throwBadRequest("Terlalu banyak permintaan. Coba lagi nanti.");
69
+ }
70
+ rateLimitMap.set(key, now);
71
+ }
72
+
73
  export const attemptRouter = router({
74
  start: protectedProcedure
75
  .input(z.object({ packageId: z.string().uuid() }))
76
  .mutation(async ({ ctx, input }) => {
77
  const userId = ctx.session.user.id;
78
+ checkRateLimit(`start:${userId}`, 3000);
79
+
80
+ // Check for existing in-progress attempt
81
+ const [activeAttempt] = await db
82
+ .select({ id: testAttempt.id })
83
+ .from(testAttempt)
84
+ .where(
85
+ and(
86
+ eq(testAttempt.userId, userId),
87
+ eq(testAttempt.packageId, input.packageId),
88
+ eq(testAttempt.status, "in_progress"),
89
+ ),
90
+ )
91
+ .limit(1);
92
+ if (activeAttempt) {
93
+ throwBadRequest("Kamu masih punya latihan yang sedang berjalan untuk paket ini");
94
+ }
95
+
96
+ // Check cooldown (10 detik setelah selesai)
97
+ const [recentAttempt] = await db
98
+ .select({ finishedAt: testAttempt.finishedAt })
99
+ .from(testAttempt)
100
+ .where(
101
+ and(
102
+ eq(testAttempt.userId, userId),
103
+ eq(testAttempt.packageId, input.packageId),
104
+ eq(testAttempt.status, "completed"),
105
+ ),
106
+ )
107
+ .orderBy(desc(testAttempt.finishedAt))
108
+ .limit(1);
109
+ if (recentAttempt?.finishedAt) {
110
+ const elapsed = Date.now() - new Date(recentAttempt.finishedAt).getTime();
111
+ if (elapsed < 10_000) {
112
+ throwBadRequest("Tunggu 10 detik sebelum memulai latihan ulang");
113
+ }
114
+ }
115
 
116
  const [pkg] = await db
117
  .select()
 
303
  )
304
  .mutation(async ({ ctx, input }) => {
305
  const userId = ctx.session.user.id;
306
+ checkRateLimit(`submit:${userId}`, 500);
307
 
308
  const [attempt] = await db
309
  .select()
 
362
  .input(z.object({ attemptId: z.string().uuid() }))
363
  .mutation(async ({ ctx, input }) => {
364
  const userId = ctx.session.user.id;
365
+ checkRateLimit(`finish:${userId}`, 3000);
366
 
367
  const [attempt] = await db
368
  .select()
 
374
  if (attempt.userId !== userId) throwForbidden();
375
  if (attempt.status !== "in_progress") throwBadRequest("Attempt already finished");
376
 
377
+ // Timer validation: must have spent at least 5 seconds
378
+ if (attempt.startedAt) {
379
+ const elapsedSec = Math.round((Date.now() - new Date(attempt.startedAt).getTime()) / 1000);
380
+ if (elapsedSec < 5) {
381
+ throwBadRequest("Latihan terlalu cepat. Harap kerjakan soal dengan benar.");
382
+ }
383
+ }
384
+
385
  const dbSections = await db
386
  .select()
387
  .from(sectionResult)
packages/api/src/routers/combo.ts CHANGED
@@ -186,12 +186,18 @@ export const comboRouter = router({
186
  }
187
  }
188
 
 
 
189
  const sectionsWithQuestions = sections.map((section) => ({
190
  ...section,
191
  questions: sectionQuestions
192
  .filter((sq) => sq.sectionId === section.sourceSectionId)
193
  .sort((a, b) => a.orderIndex - b.orderIndex)
194
- .map((sq) => sq.question),
 
 
 
 
195
  }));
196
 
197
  return { ...combo, sections: sectionsWithQuestions };
 
186
  }
187
  }
188
 
189
+ const isOwner = combo.creatorUserId === userId;
190
+
191
  const sectionsWithQuestions = sections.map((section) => ({
192
  ...section,
193
  questions: sectionQuestions
194
  .filter((sq) => sq.sectionId === section.sourceSectionId)
195
  .sort((a, b) => a.orderIndex - b.orderIndex)
196
+ .map((sq) => {
197
+ if (isOwner) return sq.question;
198
+ const { correctAnswer, explanation, ...rest } = sq.question;
199
+ return rest;
200
+ }),
201
  }));
202
 
203
  return { ...combo, sections: sectionsWithQuestions };
packages/api/src/routers/package.ts CHANGED
@@ -219,12 +219,18 @@ export const packageRouter = router({
219
  }
220
  }
221
 
 
 
222
  const sectionsWithQuestions = sections.map((section) => ({
223
  ...section,
224
  questions: sectionQuestions
225
  .filter((sq) => sq.sectionId === section.id && sq.question != null)
226
  .sort((a, b) => a.orderIndex - b.orderIndex)
227
- .map((sq) => sq.question),
 
 
 
 
228
  }));
229
 
230
  return { ...pkg, sections: sectionsWithQuestions };
 
219
  }
220
  }
221
 
222
+ const isOwner = pkg.creatorUserId === userId;
223
+
224
  const sectionsWithQuestions = sections.map((section) => ({
225
  ...section,
226
  questions: sectionQuestions
227
  .filter((sq) => sq.sectionId === section.id && sq.question != null)
228
  .sort((a, b) => a.orderIndex - b.orderIndex)
229
+ .map((sq) => {
230
+ if (isOwner) return sq.question;
231
+ const { correctAnswer, explanation, ...rest } = sq.question;
232
+ return rest;
233
+ }),
234
  }));
235
 
236
  return { ...pkg, sections: sectionsWithQuestions };
packages/api/src/routers/question.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { z } from "zod";
2
- import { eq, and, desc, sql, like, or } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import { question, examType, sectionType, user } from "@labas/db";
@@ -46,6 +46,11 @@ function buildSearchCondition(term: string) {
46
  );
47
  }
48
 
 
 
 
 
 
49
  export const questionRouter = router({
50
  list: publicProcedure
51
  .input(
@@ -108,7 +113,7 @@ export const questionRouter = router({
108
  .from(question)
109
  .where(where);
110
 
111
- return { questions: rows, total: Number(countResult?.count ?? 0) };
112
  }),
113
 
114
  myQuestions: protectedProcedure
@@ -186,7 +191,8 @@ export const questionRouter = router({
186
  if (!row) return null;
187
  if (!row.isPublic && row.creatorUserId !== userId) return null;
188
 
189
- return row;
 
190
  }),
191
 
192
  create: protectedProcedure
@@ -280,4 +286,24 @@ export const questionRouter = router({
280
  await db.delete(question).where(eq(question.id, input.id));
281
  return { success: true };
282
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  });
 
1
  import { z } from "zod";
2
+ import { eq, and, desc, sql, like, or, inArray } from "drizzle-orm";
3
  import { router, protectedProcedure, publicProcedure } from "../index";
4
  import { db } from "@labas/db";
5
  import { question, examType, sectionType, user } from "@labas/db";
 
46
  );
47
  }
48
 
49
+ function stripAnswer<T extends { correctAnswer?: string; explanation?: string | null }>(row: T) {
50
+ const { correctAnswer, explanation, ...rest } = row;
51
+ return rest as Omit<T, "correctAnswer" | "explanation">;
52
+ }
53
+
54
  export const questionRouter = router({
55
  list: publicProcedure
56
  .input(
 
113
  .from(question)
114
  .where(where);
115
 
116
+ return { questions: rows.map(stripAnswer), total: Number(countResult?.count ?? 0) };
117
  }),
118
 
119
  myQuestions: protectedProcedure
 
191
  if (!row) return null;
192
  if (!row.isPublic && row.creatorUserId !== userId) return null;
193
 
194
+ if (row.creatorUserId === userId) return row;
195
+ return stripAnswer(row);
196
  }),
197
 
198
  create: protectedProcedure
 
286
  await db.delete(question).where(eq(question.id, input.id));
287
  return { success: true };
288
  }),
289
+
290
+ bulkPublish: protectedProcedure
291
+ .input(z.object({ ids: z.array(z.string().uuid()) }))
292
+ .mutation(async ({ ctx, input }) => {
293
+ const rows = await db
294
+ .select({ id: question.id, creatorUserId: question.creatorUserId, isPublic: question.isPublic })
295
+ .from(question)
296
+ .where(inArray(question.id, input.ids));
297
+
298
+ for (const row of rows) {
299
+ assertOwnership(row, ctx.session.user.id, "Question");
300
+ }
301
+
302
+ await db
303
+ .update(question)
304
+ .set({ isPublic: true })
305
+ .where(inArray(question.id, input.ids));
306
+
307
+ return { success: true, updated: rows.length };
308
+ }),
309
  });