rogasper commited on
Commit
2a80c9b
·
1 Parent(s): 439093c

feat: add support section to README, implement donation modal, and enhance routing with new dashboard and not found pages. Update sitemap for improved SEO and adjust navigation links to point to the dashboard. Introduce new package card component for better package management and enhance error handling in various components.

Browse files
Files changed (39) hide show
  1. README.md +8 -0
  2. apps/server/src/index.ts +1 -1
  3. apps/web/public/not_found.png +3 -0
  4. apps/web/src/components/DonationModal.tsx +58 -0
  5. apps/web/src/components/ErrorFallback.tsx +1 -1
  6. apps/web/src/components/NotFoundPage.tsx +37 -0
  7. apps/web/src/components/bank/CalloutCard.tsx +3 -2
  8. apps/web/src/components/bank/QuestionCard.tsx +22 -6
  9. apps/web/src/components/bank/SoalBrowser.tsx +60 -66
  10. apps/web/src/components/packages/PackageCard.tsx +189 -0
  11. apps/web/src/components/routes/BankPage.tsx +32 -2
  12. apps/web/src/components/routes/HistoryPage.tsx +1 -1
  13. apps/web/src/components/routes/LandingPage.tsx +13 -15
  14. apps/web/src/components/routes/PackagesPage.tsx +42 -157
  15. apps/web/src/components/routes/SettingsPage.tsx +1 -1
  16. apps/web/src/components/sidebar.tsx +4 -4
  17. apps/web/src/components/sign-in-form.tsx +2 -2
  18. apps/web/src/hooks/use-generation-jobs.ts +2 -0
  19. apps/web/src/hooks/use-test-session.ts +1 -0
  20. apps/web/src/lib/route-shell.ts +7 -0
  21. apps/web/src/routeTree.gen.ts +44 -2
  22. apps/web/src/routes/$.tsx +8 -0
  23. apps/web/src/routes/__root.tsx +12 -14
  24. apps/web/src/routes/admin.tsx +4 -2
  25. apps/web/src/routes/analytics.tsx +1 -1
  26. apps/web/src/routes/attempt.$id.tsx +218 -190
  27. apps/web/src/routes/dashboard.tsx +429 -0
  28. apps/web/src/routes/forgot-password.tsx +2 -0
  29. apps/web/src/routes/{landing.lazy.tsx → index.lazy.tsx} +1 -1
  30. apps/web/src/routes/index.tsx +22 -390
  31. apps/web/src/routes/landing.tsx +4 -22
  32. apps/web/src/routes/leaderboard.tsx +1 -1
  33. apps/web/src/routes/login.tsx +2 -0
  34. apps/web/src/routes/me.tsx +1 -1
  35. apps/web/src/routes/package.$id.attempt.$attemptId.tsx +2 -0
  36. apps/web/src/routes/package.$id.index.tsx +94 -90
  37. apps/web/src/routes/package.$id.take.tsx +2 -0
  38. apps/web/src/routes/setup-avatar.tsx +4 -2
  39. apps/web/src/routes/verify-email.tsx +3 -1
README.md CHANGED
@@ -14,6 +14,7 @@
14
  <a href="#features">Features</a> ·
15
  <a href="#supported-exams">Exams</a> ·
16
  <a href="#project-structure">Structure</a> ·
 
17
  <a href="#license">License</a>
18
  </p>
19
 
@@ -277,6 +278,13 @@ Contributions are welcome! Before opening a PR:
277
 
278
  For AI-related changes, see the **AI Generation Context** section in `AGENTS.md` — adding a new question format touches schemas, prompts, attempt normalization, and frontend constants.
279
 
 
 
 
 
 
 
 
280
  ## License
281
 
282
  This project is licensed under the **[GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE)**.
 
14
  <a href="#features">Features</a> ·
15
  <a href="#supported-exams">Exams</a> ·
16
  <a href="#project-structure">Structure</a> ·
17
+ <a href="#support">Support</a> ·
18
  <a href="#license">License</a>
19
  </p>
20
 
 
278
 
279
  For AI-related changes, see the **AI Generation Context** section in `AGENTS.md` — adding a new question format touches schemas, prompts, attempt normalization, and frontend constants.
280
 
281
+ ## Support
282
+
283
+ If you find Labas helpful and want to support its ongoing development, consider buying the creator a coffee! Your support helps cover API and hosting costs. ☕
284
+
285
+ - **[Saweria](https://saweria.co/rogasper)** (Indonesian supporters)
286
+ - **[Ko-fi](https://ko-fi.com/rogasper)** (International)
287
+
288
  ## License
289
 
290
  This project is licensed under the **[GNU Affero General Public License v3.0 (AGPL-3.0)](./LICENSE)**.
apps/server/src/index.ts CHANGED
@@ -59,7 +59,7 @@ app.use(
59
  app.get("/sitemap.xml", (c) => {
60
  const baseUrl = "https://labas.rogasper.com";
61
  const urls = [
62
- { loc: `${baseUrl}/landing`, changefreq: "weekly", priority: "0.8" },
63
  ];
64
  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
65
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
 
59
  app.get("/sitemap.xml", (c) => {
60
  const baseUrl = "https://labas.rogasper.com";
61
  const urls = [
62
+ { loc: `${baseUrl}/`, changefreq: "weekly", priority: "1.0" },
63
  ];
64
  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
65
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
apps/web/public/not_found.png ADDED

Git LFS Details

  • SHA256: 6b61bc6630cedc32ab9da77c9267563eae1a3752e5969ddf485d61d6ccbfb8fc
  • Pointer size: 131 Bytes
  • Size of remote file: 556 kB
apps/web/src/components/DonationModal.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@labas/ui/components/dialog";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+
4
+ export function DonationModal({
5
+ isOpen,
6
+ onOpenChange,
7
+ triggerAction
8
+ }: {
9
+ isOpen: boolean;
10
+ onOpenChange: (open: boolean) => void;
11
+ triggerAction: "exam" | "generate" | null;
12
+ }) {
13
+ return (
14
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
15
+ <DialogContent className="sm:max-w-[425px] p-6 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
16
+ <DialogHeader>
17
+ <div className="mx-auto w-16 h-16 bg-[var(--pomegranate-400)]/10 rounded-full flex items-center justify-center mb-2">
18
+ <MaterialIcon name="favorite" className="text-4xl text-[var(--pomegranate-400)]" />
19
+ </div>
20
+ <DialogTitle className="text-center text-2xl font-headline font-extrabold text-[var(--clay-black)]">
21
+ Dukung Labas!
22
+ </DialogTitle>
23
+ <DialogDescription className="text-center text-base text-[var(--warm-charcoal)] pt-2 leading-relaxed">
24
+ Selamat, kamu baru saja menyelesaikan {triggerAction === "exam" ? "latihan soal" : "generate soal AI"}! 🎉
25
+ <br/><br/>
26
+ Jika Labas membantumu, dukung kami untuk menutupi biaya server & mengembangkan fitur baru dengan mentraktir kopi. ☕
27
+ </DialogDescription>
28
+ </DialogHeader>
29
+ <div className="flex flex-col gap-3 mt-6">
30
+ <a
31
+ href="https://saweria.co/rogasper"
32
+ target="_blank"
33
+ rel="noopener noreferrer"
34
+ className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-[var(--radius-lg)] font-bold transition-all bg-[#F3B63A] text-black hover:brightness-95 clay-shadow clay-hover"
35
+ >
36
+ <MaterialIcon name="favorite" className="text-xl" />
37
+ Support via Saweria
38
+ </a>
39
+ <a
40
+ href="https://ko-fi.com/rogasper"
41
+ target="_blank"
42
+ rel="noopener noreferrer"
43
+ className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-[var(--radius-lg)] font-bold transition-all bg-[#13C3FF] text-white hover:brightness-95 clay-shadow clay-hover"
44
+ >
45
+ <MaterialIcon name="local_cafe" className="text-xl" />
46
+ Support via Ko-fi
47
+ </a>
48
+ <button
49
+ onClick={() => onOpenChange(false)}
50
+ className="mt-3 text-sm text-[var(--warm-silver)] hover:text-[var(--clay-black)] font-semibold transition-colors"
51
+ >
52
+ Mungkin Nanti Saja
53
+ </button>
54
+ </div>
55
+ </DialogContent>
56
+ </Dialog>
57
+ );
58
+ }
apps/web/src/components/ErrorFallback.tsx CHANGED
@@ -25,7 +25,7 @@ export function ErrorFallback({ error, reset }: { error: Error; reset?: () => vo
25
  </Button>
26
  )}
27
  <Button
28
- onClick={() => window.location.href = "/"}
29
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
30
  >
31
  Ke Dashboard
 
25
  </Button>
26
  )}
27
  <Button
28
+ onClick={() => { window.location.href = "/dashboard"; }}
29
  className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
30
  >
31
  Ke Dashboard
apps/web/src/components/NotFoundPage.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link } from "@tanstack/react-router";
2
+ import { Button } from "@labas/ui/components/button";
3
+
4
+ export function NotFoundPage() {
5
+ return (
6
+ <div className="min-h-screen flex flex-col items-center justify-center bg-[var(--warm-cream)] px-6 py-12">
7
+ <img
8
+ src="/not_found.png"
9
+ alt=""
10
+ className="mb-8 w-full max-w-sm h-auto object-contain"
11
+ />
12
+ <div className="max-w-md text-center">
13
+ <h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-3">
14
+ Halaman Tidak Ditemukan
15
+ </h1>
16
+ <p className="text-base text-[var(--warm-charcoal)] mb-8">
17
+ Halaman yang kamu cari tidak ada atau sudah dipindahkan.
18
+ </p>
19
+ <div className="flex flex-col sm:flex-row gap-3 justify-center">
20
+ <Button
21
+ render={<Link to="/" />}
22
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-12 px-8"
23
+ >
24
+ Ke Beranda
25
+ </Button>
26
+ <Button
27
+ render={<Link to="/login" />}
28
+ variant="outline"
29
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] h-12 px-8 bg-[var(--pure-white)]"
30
+ >
31
+ Masuk
32
+ </Button>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ );
37
+ }
apps/web/src/components/bank/CalloutCard.tsx CHANGED
@@ -3,11 +3,12 @@ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
 
4
  interface CalloutCardProps {
5
  privateCount: number;
 
6
  onPublishAll: () => void;
7
  onDismiss: () => void;
8
  }
9
 
10
- export function CalloutCard({ privateCount, onPublishAll, onDismiss }: CalloutCardProps) {
11
  if (privateCount <= 0) return null;
12
 
13
  return (
@@ -20,7 +21,7 @@ export function CalloutCard({ privateCount, onPublishAll, onDismiss }: CalloutCa
20
  </div>
21
  <div className="flex-1 min-w-0">
22
  <p className="text-sm font-semibold text-[var(--clay-black)]">
23
- Kamu punya <span className="text-[var(--slushie-800)]">{privateCount} soal privat</span>
24
  </p>
25
  <p className="text-xs text-[var(--warm-charcoal)] mt-1 leading-relaxed">
26
  Soal privat hanya bisa kamu lihat. Jadikan publik agar soal bisa muncul di daftar paket soal dan diakses pengguna lain.
 
3
 
4
  interface CalloutCardProps {
5
  privateCount: number;
6
+ label?: string;
7
  onPublishAll: () => void;
8
  onDismiss: () => void;
9
  }
10
 
11
+ export function CalloutCard({ privateCount, label = "soal", onPublishAll, onDismiss }: CalloutCardProps) {
12
  if (privateCount <= 0) return null;
13
 
14
  return (
 
21
  </div>
22
  <div className="flex-1 min-w-0">
23
  <p className="text-sm font-semibold text-[var(--clay-black)]">
24
+ Kamu punya <span className="text-[var(--slushie-800)]">{privateCount} {label} privat</span>
25
  </p>
26
  <p className="text-xs text-[var(--warm-charcoal)] mt-1 leading-relaxed">
27
  Soal privat hanya bisa kamu lihat. Jadikan publik agar soal bisa muncul di daftar paket soal dan diakses pengguna lain.
apps/web/src/components/bank/QuestionCard.tsx CHANGED
@@ -117,15 +117,31 @@ export function QuestionCard({
117
  <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
118
  <button
119
  onClick={onTogglePublic}
120
- title={q.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
121
- className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors cursor-pointer flex items-center gap-1 ${
122
  q.isPublic
123
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
124
- : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
125
  }`}
126
  >
127
- {!q.isPublic && <MaterialIcon name="lock" className="text-xs" />}
128
- {q.isPublic ? "Publik" : "Privat"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  </button>
130
  <button
131
  onClick={onDelete}
 
117
  <div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
118
  <button
119
  onClick={onTogglePublic}
120
+ aria-label={q.isPublic ? "Jadikan privat" : "Jadikan publik"}
121
+ className={`group text-xs font-semibold px-3 py-1.5 rounded-full transition-all cursor-pointer flex items-center gap-1 ${
122
  q.isPublic
123
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--pomegranate-400)]/15 hover:text-[var(--pomegranate-600)]"
124
+ : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
125
  }`}
126
  >
127
+ {/* Default state icon */}
128
+ <MaterialIcon
129
+ name={q.isPublic ? "public" : "lock"}
130
+ className="text-xs group-hover:hidden"
131
+ />
132
+ {/* Hover state icon (shows intent) */}
133
+ <MaterialIcon
134
+ name={q.isPublic ? "lock" : "public"}
135
+ className="text-xs hidden group-hover:inline"
136
+ />
137
+ {/* Default label */}
138
+ <span className="group-hover:hidden">
139
+ {q.isPublic ? "Publik" : "Privat"}
140
+ </span>
141
+ {/* Hover label (shows what will happen) */}
142
+ <span className="hidden group-hover:inline">
143
+ {q.isPublic ? "Jadikan Privat" : "Jadikan Publik"}
144
+ </span>
145
  </button>
146
  <button
147
  onClick={onDelete}
apps/web/src/components/bank/SoalBrowser.tsx CHANGED
@@ -24,7 +24,8 @@ interface SoalBrowserProps {
24
  onLoadMore: () => void;
25
  onClearFilters: () => void;
26
  onBulkPublish?: (ids: string[]) => void;
27
- onPublishAllPrivate?: (ids: string[]) => void;
 
28
  }
29
 
30
  export function SoalBrowser({
@@ -46,6 +47,7 @@ export function SoalBrowser({
46
  onClearFilters,
47
  onBulkPublish,
48
  onPublishAllPrivate,
 
49
  }: SoalBrowserProps) {
50
  const sentinelRef = useRef<HTMLDivElement>(null);
51
  const isLocked = (q: Question) => !!lockedExamType && q.examTypeId !== lockedExamType;
@@ -64,19 +66,16 @@ export function SoalBrowser({
64
  ? localStorage.getItem("labas-bank-private-callout-dismissed") === "true"
65
  : false;
66
 
67
- const privateQuestions = questions.filter(
68
- (q) => !q.isPublic && q.creatorUserId === userId,
69
- );
70
 
71
  const handleDismissCallout = () => {
72
  localStorage.setItem("labas-bank-private-callout-dismissed", "true");
73
- // Force re-render by toggling a state
74
  setBulkMode((prev) => prev);
75
  };
76
 
77
  const handlePublishAllPrivate = () => {
78
- if (onPublishAllPrivate && privateQuestions.length > 0) {
79
- onPublishAllPrivate(privateQuestions.map((q) => q.id));
80
  }
81
  };
82
 
@@ -148,74 +147,69 @@ export function SoalBrowser({
148
  // ── Bulk mode toolbar ──
149
  const renderBulkToolbar = () => {
150
  if (!onBulkPublish || tab !== "mine") return null;
 
 
 
 
 
 
 
 
 
 
 
151
  return (
152
  <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)]">
153
- {bulkMode ? (
154
- <>
155
- <div className="flex items-center gap-3">
156
- <button
157
- onClick={clearSelection}
158
- className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
159
- >
160
- Batalkan ({selectedIds.size})
161
- </button>
162
- <button
163
- onClick={selectAll}
164
- className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer"
165
- >
166
- Pilih Semua
167
- </button>
168
- </div>
169
- <div className="flex items-center gap-2">
170
- <Button
171
- size="sm"
172
- disabled={selectedIds.size === 0}
173
- onClick={handleBulkPublish}
174
- className="rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] text-xs cursor-pointer"
175
- >
176
- <MaterialIcon name="public" className="text-xs mr-1" />
177
- Jadikan Publik ({selectedIds.size})
178
- </Button>
179
- <button
180
- onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
181
- className="text-xs text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer font-semibold ml-2"
182
- >
183
- Selesai
184
- </button>
185
- </div>
186
- </>
187
- ) : (
188
- <>
189
- <span className="text-xs font-semibold text-[var(--warm-charcoal)]">
190
- {questions.length} soal
191
- </span>
192
- <button
193
- onClick={() => setBulkMode(true)}
194
- className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer flex items-center gap-1"
195
- >
196
- <MaterialIcon name="select_all" className="text-sm" />
197
- Pilih Banyak
198
- </button>
199
- </>
200
- )}
201
  </div>
202
  );
203
  };
204
 
205
  return (
206
  <>
 
 
 
 
 
 
 
 
 
 
 
 
207
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
208
- {renderBulkToolbar()}
209
- <div className="md:col-span-2" />
210
-
211
- {tab === "mine" && privateQuestions.length > 0 && !calloutDismissed && (
212
- <CalloutCard
213
- privateCount={privateQuestions.length}
214
- onPublishAll={handlePublishAllPrivate}
215
- onDismiss={handleDismissCallout}
216
- />
217
- )}
218
-
219
  {questions[0] && (
220
  <div className="md:col-span-2">
221
  <QuestionCard
 
24
  onLoadMore: () => void;
25
  onClearFilters: () => void;
26
  onBulkPublish?: (ids: string[]) => void;
27
+ onPublishAllPrivate?: () => void;
28
+ totalPrivateCount?: number;
29
  }
30
 
31
  export function SoalBrowser({
 
47
  onClearFilters,
48
  onBulkPublish,
49
  onPublishAllPrivate,
50
+ totalPrivateCount,
51
  }: SoalBrowserProps) {
52
  const sentinelRef = useRef<HTMLDivElement>(null);
53
  const isLocked = (q: Question) => !!lockedExamType && q.examTypeId !== lockedExamType;
 
66
  ? localStorage.getItem("labas-bank-private-callout-dismissed") === "true"
67
  : false;
68
 
69
+ const privateCount = totalPrivateCount ?? 0;
 
 
70
 
71
  const handleDismissCallout = () => {
72
  localStorage.setItem("labas-bank-private-callout-dismissed", "true");
 
73
  setBulkMode((prev) => prev);
74
  };
75
 
76
  const handlePublishAllPrivate = () => {
77
+ if (onPublishAllPrivate && privateCount > 0) {
78
+ onPublishAllPrivate();
79
  }
80
  };
81
 
 
147
  // ── Bulk mode toolbar ──
148
  const renderBulkToolbar = () => {
149
  if (!onBulkPublish || tab !== "mine") return null;
150
+ if (!bulkMode) {
151
+ return (
152
+ <button
153
+ onClick={() => setBulkMode(true)}
154
+ className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer flex items-center gap-1 mb-2"
155
+ >
156
+ <MaterialIcon name="select_all" className="text-sm" />
157
+ Pilih Banyak
158
+ </button>
159
+ );
160
+ }
161
  return (
162
  <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)]">
163
+ <div className="flex items-center gap-3">
164
+ <button
165
+ onClick={clearSelection}
166
+ className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer"
167
+ >
168
+ Batalkan ({selectedIds.size})
169
+ </button>
170
+ <button
171
+ onClick={selectAll}
172
+ className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors cursor-pointer"
173
+ >
174
+ Pilih Semua
175
+ </button>
176
+ </div>
177
+ <div className="flex items-center gap-2">
178
+ <Button
179
+ size="sm"
180
+ disabled={selectedIds.size === 0}
181
+ onClick={handleBulkPublish}
182
+ className="rounded-[var(--radius-lg)] bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] text-xs cursor-pointer"
183
+ >
184
+ <MaterialIcon name="public" className="text-xs mr-1" />
185
+ Jadikan Publik ({selectedIds.size})
186
+ </Button>
187
+ <button
188
+ onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
189
+ className="text-xs text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors cursor-pointer font-semibold ml-2"
190
+ >
191
+ Selesai
192
+ </button>
193
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  </div>
195
  );
196
  };
197
 
198
  return (
199
  <>
200
+ {(onBulkPublish && tab === "mine") || (tab === "mine" && privateCount > 0 && !calloutDismissed) ? (
201
+ <div className="flex flex-col gap-3 mb-2">
202
+ {renderBulkToolbar()}
203
+ {tab === "mine" && privateCount > 0 && !calloutDismissed && (
204
+ <CalloutCard
205
+ privateCount={privateCount}
206
+ onPublishAll={handlePublishAllPrivate}
207
+ onDismiss={handleDismissCallout}
208
+ />
209
+ )}
210
+ </div>
211
+ ) : null}
212
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
 
 
 
 
 
 
 
 
 
 
 
213
  {questions[0] && (
214
  <div className="md:col-span-2">
215
  <QuestionCard
apps/web/src/components/packages/PackageCard.tsx ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Link, useNavigate } from "@tanstack/react-router";
2
+ import { Button } from "@labas/ui/components/button";
3
+ import { Card, CardContent } from "@labas/ui/components/card";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+ import { toast } from "sonner";
6
+
7
+ export interface PackageItem {
8
+ id: string;
9
+ title: string;
10
+ description?: string | null;
11
+ examTypeName?: string | null;
12
+ isPublic: boolean;
13
+ creatorUserId?: string | null;
14
+ totalQuestions: number;
15
+ totalSections: number;
16
+ estimatedDurationMin?: number | null;
17
+ usageCount: number;
18
+ avgRating?: number | null;
19
+ }
20
+
21
+ interface PackageCardProps {
22
+ pkg: PackageItem;
23
+ isOwner: boolean;
24
+ bulkMode: boolean;
25
+ isSelected: boolean;
26
+ isTogglePending?: boolean;
27
+ onToggleSelect: () => void;
28
+ onTogglePublic: () => void;
29
+ }
30
+
31
+ export function PackageCard({
32
+ pkg,
33
+ isOwner,
34
+ bulkMode,
35
+ isSelected,
36
+ isTogglePending = false,
37
+ onToggleSelect,
38
+ onTogglePublic,
39
+ }: PackageCardProps) {
40
+ const navigate = useNavigate();
41
+ const isPrivate = isOwner && !pkg.isPublic;
42
+
43
+ return (
44
+ <Card
45
+ className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
46
+ bulkMode && isSelected
47
+ ? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
48
+ : isPrivate && !bulkMode
49
+ ? "border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4"
50
+ : "border-[var(--oat-border)]"
51
+ }`}
52
+ >
53
+ <CardContent className="p-5 flex flex-col h-full">
54
+ {/* Clickable content area */}
55
+ <div
56
+ className="block flex-1 cursor-pointer"
57
+ onClick={bulkMode ? onToggleSelect : undefined}
58
+ >
59
+ <Link
60
+ to="/package/$id"
61
+ params={{ id: pkg.id }}
62
+ className={bulkMode ? "pointer-events-none" : ""}
63
+ >
64
+ <div className="flex items-start justify-between mb-3">
65
+ <div className="flex gap-2 flex-wrap">
66
+ {bulkMode && (
67
+ <span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
68
+ isSelected
69
+ ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
70
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
71
+ }`}>
72
+ <MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
73
+ {isSelected ? "Terpilih" : "Pilih"}
74
+ </span>
75
+ )}
76
+ {pkg.examTypeName && (
77
+ <span className="inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold leading-none whitespace-nowrap">
78
+ {pkg.examTypeName}
79
+ </span>
80
+ )}
81
+ </div>
82
+ {pkg.avgRating && (
83
+ <div className="flex items-center gap-1 text-[var(--lemon-700)] shrink-0">
84
+ <MaterialIcon name="star" className="text-sm" />
85
+ <span className="text-xs font-bold">{pkg.avgRating}</span>
86
+ </div>
87
+ )}
88
+ </div>
89
+
90
+ <h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
91
+ {pkg.title}
92
+ </h3>
93
+
94
+ {pkg.description && (
95
+ <p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
96
+ {pkg.description}
97
+ </p>
98
+ )}
99
+ </Link>
100
+
101
+ <div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
102
+ <div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
103
+ <span className="flex items-center gap-1">
104
+ <MaterialIcon name="quiz" className="text-xs" />
105
+ {pkg.totalQuestions}
106
+ </span>
107
+ <span className="flex items-center gap-1">
108
+ <MaterialIcon name="folder" className="text-xs" />
109
+ {pkg.totalSections}
110
+ </span>
111
+ {pkg.estimatedDurationMin && (
112
+ <span className="flex items-center gap-1">
113
+ <MaterialIcon name="timer" className="text-xs" />
114
+ {pkg.estimatedDurationMin}m
115
+ </span>
116
+ )}
117
+ </div>
118
+ <span className="text-xs text-[var(--warm-silver)]">
119
+ {pkg.usageCount}x digunakan
120
+ </span>
121
+ </div>
122
+ </div>
123
+
124
+ {/* Owner actions */}
125
+ {isOwner && !bulkMode && (
126
+ <div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
127
+ <button
128
+ onClick={onTogglePublic}
129
+ disabled={isTogglePending}
130
+ aria-label={pkg.isPublic ? "Jadikan privat" : "Jadikan publik"}
131
+ className={`group text-xs font-semibold px-3 py-1.5 rounded-full transition-all cursor-pointer flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed ${
132
+ pkg.isPublic
133
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--pomegranate-400)]/15 hover:text-[var(--pomegranate-600)]"
134
+ : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
135
+ }`}
136
+ >
137
+ {/* Default icon */}
138
+ <MaterialIcon
139
+ name={pkg.isPublic ? "public" : "lock"}
140
+ className="text-xs group-hover:hidden"
141
+ />
142
+ {/* Hover icon (shows intent) */}
143
+ <MaterialIcon
144
+ name={pkg.isPublic ? "lock" : "public"}
145
+ className="text-xs hidden group-hover:inline"
146
+ />
147
+ {/* Default label */}
148
+ <span className="group-hover:hidden">
149
+ {pkg.isPublic ? "Publik" : "Privat"}
150
+ </span>
151
+ {/* Hover label */}
152
+ <span className="hidden group-hover:inline">
153
+ {pkg.isPublic ? "Jadikan Privat" : "Jadikan Publik"}
154
+ </span>
155
+ </button>
156
+
157
+ {pkg.isPublic && (
158
+ <button
159
+ onClick={() => {
160
+ const url = `${window.location.origin}/package/${pkg.id}`;
161
+ navigator.clipboard.writeText(url);
162
+ toast.success("Link paket disalin!");
163
+ }}
164
+ className="text-xs text-[var(--matcha-600)] hover:bg-[var(--matcha-300)]/20 px-3 py-1.5 rounded-full transition-colors flex items-center gap-1 cursor-pointer"
165
+ >
166
+ <MaterialIcon name="share" className="text-xs" />
167
+ Bagikan
168
+ </button>
169
+ )}
170
+ </div>
171
+ )}
172
+
173
+ {/* CTA */}
174
+ {!bulkMode && (
175
+ <div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
176
+ <Button
177
+ className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
178
+ onClick={() => navigate({ to: "/package/$id/take", params: { id: pkg.id } })}
179
+ size="xl"
180
+ >
181
+ <MaterialIcon name="play_arrow" className="mr-2" />
182
+ Mulai Latihan
183
+ </Button>
184
+ </div>
185
+ )}
186
+ </CardContent>
187
+ </Card>
188
+ );
189
+ }
apps/web/src/components/routes/BankPage.tsx CHANGED
@@ -89,6 +89,14 @@ export function BankComponent() {
89
  ? { isPublic: visibilityFilter === "public" }
90
  : {};
91
 
 
 
 
 
 
 
 
 
92
  const questionQuery = useQuery(
93
  trpc.question.list.queryOptions(
94
  {
@@ -163,7 +171,14 @@ export function BankComponent() {
163
  const bulkPublish = useMutation(
164
  trpc.question.bulkPublish.mutationOptions({
165
  onSuccess: (data) => {
166
- questionQuery.refetch();
 
 
 
 
 
 
 
167
  if (data.skipped > 0) {
168
  toast.success(
169
  `${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
@@ -179,6 +194,20 @@ export function BankComponent() {
179
  }),
180
  );
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  // ── Navigation helpers ──
183
  const setMode = (newMode: Mode) => {
184
  navigate({
@@ -480,7 +509,8 @@ export function BankComponent() {
480
  }}
481
  onClearFilters={clearFilters}
482
  onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
483
- onPublishAllPrivate={(ids) => bulkPublish.mutate({ ids })}
 
484
  />
485
  ) : (
486
  <SectionBrowser
 
89
  ? { isPublic: visibilityFilter === "public" }
90
  : {};
91
 
92
+ const privateCountQuery = useQuery(
93
+ trpc.question.list.queryOptions(
94
+ { creatorUserId: userId ?? "", isPublic: false, limit: 1 },
95
+ { enabled: !!userId && mode === "soal" },
96
+ ),
97
+ );
98
+ const totalPrivateCount = privateCountQuery.data?.total ?? 0;
99
+
100
  const questionQuery = useQuery(
101
  trpc.question.list.queryOptions(
102
  {
 
171
  const bulkPublish = useMutation(
172
  trpc.question.bulkPublish.mutationOptions({
173
  onSuccess: (data) => {
174
+ // Reset accumulated infinite-scroll state so refetch shows fresh data
175
+ setAllQuestions([]);
176
+ if (offset !== 0) {
177
+ setOffset(0);
178
+ } else {
179
+ questionQuery.refetch();
180
+ }
181
+ privateCountQuery.refetch();
182
  if (data.skipped > 0) {
183
  toast.success(
184
  `${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
 
194
  }),
195
  );
196
 
197
+ const handlePublishAllPrivate = async () => {
198
+ if (!userId || totalPrivateCount === 0) return;
199
+ const allPrivate = await queryClient.fetchQuery(
200
+ trpc.question.list.queryOptions({
201
+ creatorUserId: userId,
202
+ isPublic: false,
203
+ limit: totalPrivateCount,
204
+ offset: 0,
205
+ }),
206
+ );
207
+ const ids = (allPrivate.questions ?? []).map((q) => q.id);
208
+ if (ids.length > 0) bulkPublish.mutate({ ids });
209
+ };
210
+
211
  // ── Navigation helpers ──
212
  const setMode = (newMode: Mode) => {
213
  navigate({
 
509
  }}
510
  onClearFilters={clearFilters}
511
  onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
512
+ onPublishAllPrivate={handlePublishAllPrivate}
513
+ totalPrivateCount={totalPrivateCount}
514
  />
515
  ) : (
516
  <SectionBrowser
apps/web/src/components/routes/HistoryPage.tsx CHANGED
@@ -118,7 +118,7 @@ export function HistoryComponent() {
118
  {/* Header */}
119
  <div className="mb-8">
120
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
121
- <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
122
  Beranda
123
  </Link>
124
  <MaterialIcon name="chevron_right" className="text-xs" />
 
118
  {/* Header */}
119
  <div className="mb-8">
120
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
121
+ <Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
122
  Beranda
123
  </Link>
124
  <MaterialIcon name="chevron_right" className="text-xs" />
apps/web/src/components/routes/LandingPage.tsx CHANGED
@@ -1,22 +1,8 @@
1
- import { createFileRoute, Link, redirect } from "@tanstack/react-router";
2
  import { Button } from "@labas/ui/components/button";
3
  import { Card, CardContent } from "@labas/ui/components/card";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
  import { useState } from "react";
6
- import { authClient } from "@/lib/auth-client";
7
-
8
- export const Route = createFileRoute("/landing")({
9
- beforeLoad: async () => {
10
- try {
11
- const session = await authClient.getSession();
12
- if (session?.data) {
13
- throw redirect({ to: "/", replace: true });
14
- }
15
- } catch (e) {
16
- if (e instanceof Response) throw e;
17
- }
18
- },
19
- });
20
 
21
  function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
22
  const [isOpen, setIsOpen] = useState(false);
@@ -60,6 +46,10 @@ export function LandingPage() {
60
  <span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
61
  </div>
62
  <div className="flex items-center gap-2 sm:gap-4">
 
 
 
 
63
  <a href="https://github.com/rogasper/labas-bahasa" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors p-2 hidden sm:flex items-center gap-2" aria-label="GitHub Repository">
64
  <svg viewBox="0 0 24 24" className="w-6 h-6 fill-current" aria-hidden="true">
65
  <path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
@@ -497,6 +487,14 @@ export function LandingPage() {
497
  <MaterialIcon name="code" />
498
  <span className="font-semibold">GitHub</span>
499
  </a>
 
 
 
 
 
 
 
 
500
  <a href="https://rogasper.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--matcha-700)] transition-colors">
501
  <MaterialIcon name="language" />
502
  <span className="font-semibold">rogasper.com</span>
 
1
+ import { Link } from "@tanstack/react-router";
2
  import { Button } from "@labas/ui/components/button";
3
  import { Card, CardContent } from "@labas/ui/components/card";
4
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
  import { useState } from "react";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
8
  const [isOpen, setIsOpen] = useState(false);
 
46
  <span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
47
  </div>
48
  <div className="flex items-center gap-2 sm:gap-4">
49
+ <a href="https://saweria.co/rogasper" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors p-2 hidden md:flex items-center gap-2" aria-label="Support via Saweria">
50
+ <MaterialIcon name="favorite" className="text-xl" />
51
+ <span className="font-semibold text-base">Support</span>
52
+ </a>
53
  <a href="https://github.com/rogasper/labas-bahasa" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors p-2 hidden sm:flex items-center gap-2" aria-label="GitHub Repository">
54
  <svg viewBox="0 0 24 24" className="w-6 h-6 fill-current" aria-hidden="true">
55
  <path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
 
487
  <MaterialIcon name="code" />
488
  <span className="font-semibold">GitHub</span>
489
  </a>
490
+ <a href="https://saweria.co/rogasper" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--pomegranate-400)] transition-colors">
491
+ <MaterialIcon name="favorite" />
492
+ <span className="font-semibold">Saweria</span>
493
+ </a>
494
+ <a href="https://ko-fi.com/rogasper" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--pomegranate-400)] transition-colors">
495
+ <MaterialIcon name="local_cafe" />
496
+ <span className="font-semibold">Ko-fi</span>
497
+ </a>
498
  <a href="https://rogasper.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--matcha-700)] transition-colors">
499
  <MaterialIcon name="language" />
500
  <span className="font-semibold">rogasper.com</span>
apps/web/src/components/routes/PackagesPage.tsx CHANGED
@@ -1,12 +1,12 @@
1
  import { useState, useEffect, useCallback } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
- import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { z } from "zod";
5
  import { authClient } from "@/lib/auth-client";
6
- import { trpc } from "@/utils/trpc";
7
  import { Input } from "@labas/ui/components/input";
8
  import { Button } from "@labas/ui/components/button";
9
- import { Card, CardContent } from "@labas/ui/components/card";
10
  import {
11
  Tabs,
12
  TabsList,
@@ -23,6 +23,7 @@ import {
23
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
24
  import { GettingStartedCard } from "@/components/GettingStartedCard";
25
  import { CalloutCard } from "@/components/bank/CalloutCard";
 
26
  import { PageTour, TourHelpButton } from "@/components/TourGuide";
27
  import type { Step } from "react-joyride";
28
  import { toast } from "sonner";
@@ -49,7 +50,6 @@ export const Route = createFileRoute("/packages")({
49
  type Tab = "all" | "mine";
50
 
51
  export function PackagesComponent() {
52
- const routerNavigate = useNavigate();
53
  const search = Route.useSearch();
54
  const navigate = Route.useNavigate();
55
  const { data: session } = authClient.useSession();
@@ -92,6 +92,14 @@ export function PackagesComponent() {
92
  ),
93
  );
94
 
 
 
 
 
 
 
 
 
95
  const query = tab === "all" ? allQuery : mineQuery;
96
  const packages = query.data?.packages ?? [];
97
  const total = query.data?.total ?? 0;
@@ -113,6 +121,7 @@ export function PackagesComponent() {
113
  trpc.package.bulkPublish.mutationOptions({
114
  onSuccess: (data) => {
115
  query.refetch();
 
116
  setBulkMode(false);
117
  setSelectedIds(new Set());
118
  if (data.skipped > 0) {
@@ -159,17 +168,22 @@ export function PackagesComponent() {
159
  const [calloutDismissed, setCalloutDismissed] = useState(
160
  typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
161
  );
162
- const privatePackages = packages.filter(
163
- (p) => !p.isPublic && p.creatorUserId === userId,
164
- );
165
 
166
  const handleDismissCallout = () => {
167
  localStorage.setItem("labas-packages-private-callout-dismissed", "true");
168
  setCalloutDismissed(true);
169
  };
170
 
171
- const handlePublishAllPrivate = () => {
172
- const ids = privatePackages.map((p) => p.id);
 
 
 
 
 
 
 
 
173
  if (ids.length > 0) bulkPublish.mutate({ ids });
174
  };
175
 
@@ -317,25 +331,23 @@ export function PackagesComponent() {
317
  </div>
318
  </>
319
  ) : (
320
- <>
321
- <span className="text-sm text-[var(--warm-charcoal)]">{packages.length} paket</span>
322
- <button
323
- onClick={() => setBulkMode(true)}
324
- className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
325
- >
326
- <MaterialIcon name="select_all" className="text-sm" />
327
- Pilih Banyak
328
- </button>
329
- </>
330
  )}
331
  </div>
332
  )}
333
 
334
  {/* Private package callout */}
335
- {tab === "mine" && privatePackages.length > 0 && !calloutDismissed && (
336
  <div className="mb-6">
337
  <CalloutCard
338
- privateCount={privatePackages.length}
 
339
  onPublishAll={handlePublishAllPrivate}
340
  onDismiss={handleDismissCallout}
341
  />
@@ -378,144 +390,17 @@ export function PackagesComponent() {
378
  <div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
379
  {packages.map((pkg) => {
380
  const isOwner = pkg.creatorUserId === userId;
381
- const isSelected = selectedIds.has(pkg.id);
382
  return (
383
- <Card
384
  key={pkg.id}
385
- className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
386
- bulkMode && isSelected
387
- ? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
388
- : isOwner && !pkg.isPublic && !bulkMode
389
- ? "border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4"
390
- : "border-[var(--oat-border)]"
391
- }`}
392
- >
393
- <CardContent className="p-5 flex flex-col h-full">
394
- <div
395
- className="block flex-1 cursor-pointer"
396
- onClick={bulkMode ? () => toggleSelect(pkg.id) : undefined}
397
- >
398
- <Link
399
- to="/package/$id"
400
- params={{ id: pkg.id }}
401
- className={bulkMode ? "pointer-events-none" : ""}
402
- >
403
- <div className="flex items-start justify-between mb-3">
404
- <div className="flex gap-2 flex-wrap">
405
- {bulkMode && (
406
- <span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
407
- isSelected
408
- ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
409
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
410
- }`}>
411
- <MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
412
- {isSelected ? "Terpilih" : "Pilih"}
413
- </span>
414
- )}
415
- <span className="inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold leading-none whitespace-nowrap">
416
- {pkg.examTypeName}
417
- </span>
418
- {isOwner && !bulkMode && (
419
- <span
420
- className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
421
- pkg.isPublic
422
- ? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
423
- : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
424
- }`}
425
- >
426
- {!pkg.isPublic && <MaterialIcon name="lock" className="text-[10px]" />}
427
- {pkg.isPublic ? "Publik" : "Privat"}
428
- </span>
429
- )}
430
- </div>
431
- {pkg.avgRating && (
432
- <div className="flex items-center gap-1 text-[var(--lemon-700)]">
433
- <MaterialIcon name="star" className="text-sm" />
434
- <span className="text-xs font-bold">{pkg.avgRating}</span>
435
- </div>
436
- )}
437
- </div>
438
-
439
- <h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
440
- {pkg.title}
441
- </h3>
442
-
443
- {pkg.description && (
444
- <p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
445
- {pkg.description}
446
- </p>
447
- )}
448
- </Link>
449
-
450
- <div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
451
- <div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
452
- <span className="flex items-center gap-1">
453
- <MaterialIcon name="quiz" className="text-xs" />
454
- {pkg.totalQuestions}
455
- </span>
456
- <span className="flex items-center gap-1">
457
- <MaterialIcon name="folder" className="text-xs" />
458
- {pkg.totalSections}
459
- </span>
460
- {pkg.estimatedDurationMin && (
461
- <span className="flex items-center gap-1">
462
- <MaterialIcon name="timer" className="text-xs" />
463
- {pkg.estimatedDurationMin}m
464
- </span>
465
- )}
466
- </div>
467
- <span className="text-xs text-[var(--warm-silver)]">
468
- {pkg.usageCount}x digunakan
469
- </span>
470
- </div>
471
- </div>
472
-
473
- {/* Owner actions */}
474
- {isOwner && !bulkMode && (
475
- <div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
476
- <button
477
- onClick={() => togglePublic(pkg.id, pkg.isPublic)}
478
- disabled={updateMutation.isPending}
479
- title={pkg.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
480
- className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors flex items-center gap-1 cursor-pointer ${
481
- pkg.isPublic
482
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
483
- : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
484
- }`}
485
- >
486
- {!pkg.isPublic && <MaterialIcon name="lock" className="text-xs" />}
487
- {pkg.isPublic ? "Publik" : "Privat"}
488
- </button>
489
- {pkg.isPublic && (
490
- <button
491
- onClick={() => {
492
- const url = `${window.location.origin}/package/${pkg.id}`;
493
- navigator.clipboard.writeText(url);
494
- toast.success("Link paket disalin!");
495
- }}
496
- className="text-xs text-[var(--matcha-600)] hover:bg-[var(--matcha-300)]/20 px-3 py-1.5 rounded-full transition-colors flex items-center gap-1"
497
- >
498
- <MaterialIcon name="share" className="text-xs" />
499
- Bagikan
500
- </button>
501
- )}
502
- </div>
503
- )}
504
-
505
- {!bulkMode && (
506
- <div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
507
- <Button
508
- className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
509
- onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
510
- size="xl"
511
- >
512
- <MaterialIcon name="play_arrow" className="mr-2" />
513
- Mulai Latihan
514
- </Button>
515
- </div>
516
- )}
517
- </CardContent>
518
- </Card>
519
  );
520
  })}
521
  </div>
 
1
  import { useState, useEffect, useCallback } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { z } from "zod";
5
  import { authClient } from "@/lib/auth-client";
6
+ import { trpc, queryClient } from "@/utils/trpc";
7
  import { Input } from "@labas/ui/components/input";
8
  import { Button } from "@labas/ui/components/button";
9
+ import { Card } from "@labas/ui/components/card";
10
  import {
11
  Tabs,
12
  TabsList,
 
23
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
24
  import { GettingStartedCard } from "@/components/GettingStartedCard";
25
  import { CalloutCard } from "@/components/bank/CalloutCard";
26
+ import { PackageCard } from "@/components/packages/PackageCard";
27
  import { PageTour, TourHelpButton } from "@/components/TourGuide";
28
  import type { Step } from "react-joyride";
29
  import { toast } from "sonner";
 
50
  type Tab = "all" | "mine";
51
 
52
  export function PackagesComponent() {
 
53
  const search = Route.useSearch();
54
  const navigate = Route.useNavigate();
55
  const { data: session } = authClient.useSession();
 
92
  ),
93
  );
94
 
95
+ const privateCountQuery = useQuery(
96
+ trpc.package.myPackages.queryOptions(
97
+ { isPublic: false, limit: 1 },
98
+ { enabled: !!userId && tab === "mine" },
99
+ ),
100
+ );
101
+ const totalPrivateCount = privateCountQuery.data?.total ?? 0;
102
+
103
  const query = tab === "all" ? allQuery : mineQuery;
104
  const packages = query.data?.packages ?? [];
105
  const total = query.data?.total ?? 0;
 
121
  trpc.package.bulkPublish.mutationOptions({
122
  onSuccess: (data) => {
123
  query.refetch();
124
+ privateCountQuery.refetch();
125
  setBulkMode(false);
126
  setSelectedIds(new Set());
127
  if (data.skipped > 0) {
 
168
  const [calloutDismissed, setCalloutDismissed] = useState(
169
  typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
170
  );
 
 
 
171
 
172
  const handleDismissCallout = () => {
173
  localStorage.setItem("labas-packages-private-callout-dismissed", "true");
174
  setCalloutDismissed(true);
175
  };
176
 
177
+ const handlePublishAllPrivate = async () => {
178
+ if (!userId || totalPrivateCount === 0) return;
179
+ const allPrivate = await queryClient.fetchQuery(
180
+ trpc.package.myPackages.queryOptions({
181
+ isPublic: false,
182
+ limit: totalPrivateCount,
183
+ offset: 0,
184
+ }),
185
+ );
186
+ const ids = (allPrivate.packages ?? []).map((p) => p.id);
187
  if (ids.length > 0) bulkPublish.mutate({ ids });
188
  };
189
 
 
331
  </div>
332
  </>
333
  ) : (
334
+ <button
335
+ onClick={() => setBulkMode(true)}
336
+ className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1 ml-auto"
337
+ >
338
+ <MaterialIcon name="select_all" className="text-sm" />
339
+ Pilih Banyak
340
+ </button>
 
 
 
341
  )}
342
  </div>
343
  )}
344
 
345
  {/* Private package callout */}
346
+ {tab === "mine" && totalPrivateCount > 0 && !calloutDismissed && (
347
  <div className="mb-6">
348
  <CalloutCard
349
+ privateCount={totalPrivateCount}
350
+ label="paket"
351
  onPublishAll={handlePublishAllPrivate}
352
  onDismiss={handleDismissCallout}
353
  />
 
390
  <div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
391
  {packages.map((pkg) => {
392
  const isOwner = pkg.creatorUserId === userId;
 
393
  return (
394
+ <PackageCard
395
  key={pkg.id}
396
+ pkg={pkg}
397
+ isOwner={isOwner}
398
+ bulkMode={bulkMode}
399
+ isSelected={selectedIds.has(pkg.id)}
400
+ isTogglePending={updateMutation.isPending}
401
+ onToggleSelect={() => toggleSelect(pkg.id)}
402
+ onTogglePublic={() => togglePublic(pkg.id, pkg.isPublic)}
403
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  );
405
  })}
406
  </div>
apps/web/src/components/routes/SettingsPage.tsx CHANGED
@@ -280,7 +280,7 @@ export function RouteComponent() {
280
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
281
  <section className="mb-10">
282
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
283
- <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
284
  <MaterialIcon name="chevron_right" className="text-xs" />
285
  <span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
286
  </div>
 
280
  <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
281
  <section className="mb-10">
282
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
283
+ <Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
284
  <MaterialIcon name="chevron_right" className="text-xs" />
285
  <span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
286
  </div>
apps/web/src/components/sidebar.tsx CHANGED
@@ -19,7 +19,7 @@ interface NavGroup {
19
 
20
  const navGroups: NavGroup[] = [
21
  {
22
- items: [{ to: "/", label: "Dashboard", icon: "dashboard" }],
23
  },
24
  {
25
  label: "Generate",
@@ -54,7 +54,7 @@ const bottomItems: NavItem[] = [
54
  ];
55
 
56
  const mobileNavItems: NavItem[] = [
57
- { to: "/", label: "Dashboard", icon: "dashboard" },
58
  { to: "/generate", label: "AI Lab", icon: "auto_awesome" },
59
  { to: "/jobs", label: "Jobs", icon: "schedule" },
60
  { to: "/bank", label: "Buat", icon: "database" },
@@ -66,7 +66,7 @@ function NavIcon({ name }: { name: string }) {
66
  }
67
 
68
  function NavLink({ item, isActive, collapsed }: { item: NavItem; isActive: boolean; collapsed: boolean }) {
69
- const tourAttr = item.to !== "/" ? { "data-tour": `nav-${item.to.replace("/", "")}` } : {};
70
  return (
71
  <Link
72
  to={item.to}
@@ -99,7 +99,7 @@ export function Sidebar() {
99
 
100
  async function handleSignOut() {
101
  await authClient.signOut();
102
- navigate({ to: "/landing" });
103
  }
104
 
105
  return (
 
19
 
20
  const navGroups: NavGroup[] = [
21
  {
22
+ items: [{ to: "/dashboard", label: "Dashboard", icon: "dashboard" }],
23
  },
24
  {
25
  label: "Generate",
 
54
  ];
55
 
56
  const mobileNavItems: NavItem[] = [
57
+ { to: "/dashboard", label: "Dashboard", icon: "dashboard" },
58
  { to: "/generate", label: "AI Lab", icon: "auto_awesome" },
59
  { to: "/jobs", label: "Jobs", icon: "schedule" },
60
  { to: "/bank", label: "Buat", icon: "database" },
 
66
  }
67
 
68
  function NavLink({ item, isActive, collapsed }: { item: NavItem; isActive: boolean; collapsed: boolean }) {
69
+ const tourAttr = item.to !== "/dashboard" ? { "data-tour": `nav-${item.to.replace("/", "")}` } : {};
70
  return (
71
  <Link
72
  to={item.to}
 
99
 
100
  async function handleSignOut() {
101
  await authClient.signOut();
102
+ navigate({ to: "/" });
103
  }
104
 
105
  return (
apps/web/src/components/sign-in-form.tsx CHANGED
@@ -13,7 +13,7 @@ import Loader from "./loader";
13
 
14
  export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) {
15
  const navigate = useNavigate({
16
- from: "/",
17
  });
18
  const { isPending } = authClient.useSession();
19
 
@@ -30,7 +30,7 @@ export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: ()
30
  },
31
  {
32
  onSuccess: () => {
33
- navigate({ to: "/" });
34
  toast.success("Sign in successful");
35
  },
36
  onError: (error) => {
 
13
 
14
  export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) {
15
  const navigate = useNavigate({
16
+ from: "/login",
17
  });
18
  const { isPending } = authClient.useSession();
19
 
 
30
  },
31
  {
32
  onSuccess: () => {
33
+ navigate({ to: "/dashboard" });
34
  toast.success("Sign in successful");
35
  },
36
  onError: (error) => {
apps/web/src/hooks/use-generation-jobs.ts CHANGED
@@ -92,6 +92,7 @@ export function useGenerationJobs() {
92
 
93
  if (status === "completed_partial" && data.resultJson) {
94
  setResult(baseResult);
 
95
  if (data.errorMessage) {
96
  setError(data.errorMessage as string);
97
  }
@@ -99,6 +100,7 @@ export function useGenerationJobs() {
99
 
100
  if (status === "completed" && data.resultJson) {
101
  setResult(baseResult);
 
102
  }
103
 
104
  if (status === "failed") {
 
92
 
93
  if (status === "completed_partial" && data.resultJson) {
94
  setResult(baseResult);
95
+ localStorage.setItem("pendingDonationPrompt", "generate");
96
  if (data.errorMessage) {
97
  setError(data.errorMessage as string);
98
  }
 
100
 
101
  if (status === "completed" && data.resultJson) {
102
  setResult(baseResult);
103
+ localStorage.setItem("pendingDonationPrompt", "generate");
104
  }
105
 
106
  if (status === "failed") {
apps/web/src/hooks/use-test-session.ts CHANGED
@@ -211,6 +211,7 @@ export function useTestSession(packageId: string, existingAttemptId?: string) {
211
  const handleFinish = useCallback(async () => {
212
  if (!attemptId) return;
213
  setIsFinished(true);
 
214
  await finishMutation.mutateAsync({ attemptId });
215
  clearElapsedTime(attemptId);
216
  clearMarkedQuestions(attemptId);
 
211
  const handleFinish = useCallback(async () => {
212
  if (!attemptId) return;
213
  setIsFinished(true);
214
+ localStorage.setItem("pendingDonationPrompt", "exam");
215
  await finishMutation.mutateAsync({ attemptId });
216
  clearElapsedTime(attemptId);
217
  clearMarkedQuestions(attemptId);
apps/web/src/lib/route-shell.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export type RouteShell = "public" | "fullscreen" | "app";
2
+
3
+ export const routeShell = {
4
+ public: { shell: "public" as const },
5
+ fullscreen: { shell: "fullscreen" as const },
6
+ app: { shell: "app" as const },
7
+ };
apps/web/src/routeTree.gen.ts CHANGED
@@ -21,9 +21,11 @@ import { Route as JobsRouteImport } from './routes/jobs'
21
  import { Route as HistoryRouteImport } from './routes/history'
22
  import { Route as GenerateRouteImport } from './routes/generate'
23
  import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
 
24
  import { Route as BankRouteImport } from './routes/bank'
25
  import { Route as AnalyticsRouteImport } from './routes/analytics'
26
  import { Route as AdminRouteImport } from './routes/admin'
 
27
  import { Route as IndexRouteImport } from './routes/index'
28
  import { Route as AdminIndexRouteImport } from './routes/admin.index'
29
  import { Route as ProfileUserIdRouteImport } from './routes/profile.$userId'
@@ -77,7 +79,7 @@ const LandingRoute = LandingRouteImport.update({
77
  id: '/landing',
78
  path: '/landing',
79
  getParentRoute: () => rootRouteImport,
80
- } as any).lazy(() => import('./routes/landing.lazy').then((d) => d.Route))
81
  const JobsRoute = JobsRouteImport.update({
82
  id: '/jobs',
83
  path: '/jobs',
@@ -98,6 +100,11 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
98
  path: '/forgot-password',
99
  getParentRoute: () => rootRouteImport,
100
  } as any)
 
 
 
 
 
101
  const BankRoute = BankRouteImport.update({
102
  id: '/bank',
103
  path: '/bank',
@@ -113,11 +120,16 @@ const AdminRoute = AdminRouteImport.update({
113
  path: '/admin',
114
  getParentRoute: () => rootRouteImport,
115
  } as any)
 
 
 
 
 
116
  const IndexRoute = IndexRouteImport.update({
117
  id: '/',
118
  path: '/',
119
  getParentRoute: () => rootRouteImport,
120
- } as any)
121
  const AdminIndexRoute = AdminIndexRouteImport.update({
122
  id: '/',
123
  path: '/',
@@ -182,9 +194,11 @@ const PackageIdAttemptAttemptIdRoute =
182
 
183
  export interface FileRoutesByFullPath {
184
  '/': typeof IndexRoute
 
185
  '/admin': typeof AdminRouteWithChildren
186
  '/analytics': typeof AnalyticsRoute
187
  '/bank': typeof BankRoute
 
188
  '/forgot-password': typeof ForgotPasswordRoute
189
  '/generate': typeof GenerateRoute
190
  '/history': typeof HistoryRoute
@@ -212,8 +226,10 @@ export interface FileRoutesByFullPath {
212
  }
213
  export interface FileRoutesByTo {
214
  '/': typeof IndexRoute
 
215
  '/analytics': typeof AnalyticsRoute
216
  '/bank': typeof BankRoute
 
217
  '/forgot-password': typeof ForgotPasswordRoute
218
  '/generate': typeof GenerateRoute
219
  '/history': typeof HistoryRoute
@@ -241,9 +257,11 @@ export interface FileRoutesByTo {
241
  export interface FileRoutesById {
242
  __root__: typeof rootRouteImport
243
  '/': typeof IndexRoute
 
244
  '/admin': typeof AdminRouteWithChildren
245
  '/analytics': typeof AnalyticsRoute
246
  '/bank': typeof BankRoute
 
247
  '/forgot-password': typeof ForgotPasswordRoute
248
  '/generate': typeof GenerateRoute
249
  '/history': typeof HistoryRoute
@@ -273,9 +291,11 @@ export interface FileRouteTypes {
273
  fileRoutesByFullPath: FileRoutesByFullPath
274
  fullPaths:
275
  | '/'
 
276
  | '/admin'
277
  | '/analytics'
278
  | '/bank'
 
279
  | '/forgot-password'
280
  | '/generate'
281
  | '/history'
@@ -303,8 +323,10 @@ export interface FileRouteTypes {
303
  fileRoutesByTo: FileRoutesByTo
304
  to:
305
  | '/'
 
306
  | '/analytics'
307
  | '/bank'
 
308
  | '/forgot-password'
309
  | '/generate'
310
  | '/history'
@@ -331,9 +353,11 @@ export interface FileRouteTypes {
331
  id:
332
  | '__root__'
333
  | '/'
 
334
  | '/admin'
335
  | '/analytics'
336
  | '/bank'
 
337
  | '/forgot-password'
338
  | '/generate'
339
  | '/history'
@@ -362,9 +386,11 @@ export interface FileRouteTypes {
362
  }
363
  export interface RootRouteChildren {
364
  IndexRoute: typeof IndexRoute
 
365
  AdminRoute: typeof AdminRouteWithChildren
366
  AnalyticsRoute: typeof AnalyticsRoute
367
  BankRoute: typeof BankRoute
 
368
  ForgotPasswordRoute: typeof ForgotPasswordRoute
369
  GenerateRoute: typeof GenerateRoute
370
  HistoryRoute: typeof HistoryRoute
@@ -468,6 +494,13 @@ declare module '@tanstack/react-router' {
468
  preLoaderRoute: typeof ForgotPasswordRouteImport
469
  parentRoute: typeof rootRouteImport
470
  }
 
 
 
 
 
 
 
471
  '/bank': {
472
  id: '/bank'
473
  path: '/bank'
@@ -489,6 +522,13 @@ declare module '@tanstack/react-router' {
489
  preLoaderRoute: typeof AdminRouteImport
490
  parentRoute: typeof rootRouteImport
491
  }
 
 
 
 
 
 
 
492
  '/': {
493
  id: '/'
494
  path: '/'
@@ -621,9 +661,11 @@ const PackageIdRouteWithChildren = PackageIdRoute._addFileChildren(
621
 
622
  const rootRouteChildren: RootRouteChildren = {
623
  IndexRoute: IndexRoute,
 
624
  AdminRoute: AdminRouteWithChildren,
625
  AnalyticsRoute: AnalyticsRoute,
626
  BankRoute: BankRoute,
 
627
  ForgotPasswordRoute: ForgotPasswordRoute,
628
  GenerateRoute: GenerateRoute,
629
  HistoryRoute: HistoryRoute,
 
21
  import { Route as HistoryRouteImport } from './routes/history'
22
  import { Route as GenerateRouteImport } from './routes/generate'
23
  import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
24
+ import { Route as DashboardRouteImport } from './routes/dashboard'
25
  import { Route as BankRouteImport } from './routes/bank'
26
  import { Route as AnalyticsRouteImport } from './routes/analytics'
27
  import { Route as AdminRouteImport } from './routes/admin'
28
+ import { Route as SplatRouteImport } from './routes/$'
29
  import { Route as IndexRouteImport } from './routes/index'
30
  import { Route as AdminIndexRouteImport } from './routes/admin.index'
31
  import { Route as ProfileUserIdRouteImport } from './routes/profile.$userId'
 
79
  id: '/landing',
80
  path: '/landing',
81
  getParentRoute: () => rootRouteImport,
82
+ } as any)
83
  const JobsRoute = JobsRouteImport.update({
84
  id: '/jobs',
85
  path: '/jobs',
 
100
  path: '/forgot-password',
101
  getParentRoute: () => rootRouteImport,
102
  } as any)
103
+ const DashboardRoute = DashboardRouteImport.update({
104
+ id: '/dashboard',
105
+ path: '/dashboard',
106
+ getParentRoute: () => rootRouteImport,
107
+ } as any)
108
  const BankRoute = BankRouteImport.update({
109
  id: '/bank',
110
  path: '/bank',
 
120
  path: '/admin',
121
  getParentRoute: () => rootRouteImport,
122
  } as any)
123
+ const SplatRoute = SplatRouteImport.update({
124
+ id: '/$',
125
+ path: '/$',
126
+ getParentRoute: () => rootRouteImport,
127
+ } as any)
128
  const IndexRoute = IndexRouteImport.update({
129
  id: '/',
130
  path: '/',
131
  getParentRoute: () => rootRouteImport,
132
+ } as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
133
  const AdminIndexRoute = AdminIndexRouteImport.update({
134
  id: '/',
135
  path: '/',
 
194
 
195
  export interface FileRoutesByFullPath {
196
  '/': typeof IndexRoute
197
+ '/$': typeof SplatRoute
198
  '/admin': typeof AdminRouteWithChildren
199
  '/analytics': typeof AnalyticsRoute
200
  '/bank': typeof BankRoute
201
+ '/dashboard': typeof DashboardRoute
202
  '/forgot-password': typeof ForgotPasswordRoute
203
  '/generate': typeof GenerateRoute
204
  '/history': typeof HistoryRoute
 
226
  }
227
  export interface FileRoutesByTo {
228
  '/': typeof IndexRoute
229
+ '/$': typeof SplatRoute
230
  '/analytics': typeof AnalyticsRoute
231
  '/bank': typeof BankRoute
232
+ '/dashboard': typeof DashboardRoute
233
  '/forgot-password': typeof ForgotPasswordRoute
234
  '/generate': typeof GenerateRoute
235
  '/history': typeof HistoryRoute
 
257
  export interface FileRoutesById {
258
  __root__: typeof rootRouteImport
259
  '/': typeof IndexRoute
260
+ '/$': typeof SplatRoute
261
  '/admin': typeof AdminRouteWithChildren
262
  '/analytics': typeof AnalyticsRoute
263
  '/bank': typeof BankRoute
264
+ '/dashboard': typeof DashboardRoute
265
  '/forgot-password': typeof ForgotPasswordRoute
266
  '/generate': typeof GenerateRoute
267
  '/history': typeof HistoryRoute
 
291
  fileRoutesByFullPath: FileRoutesByFullPath
292
  fullPaths:
293
  | '/'
294
+ | '/$'
295
  | '/admin'
296
  | '/analytics'
297
  | '/bank'
298
+ | '/dashboard'
299
  | '/forgot-password'
300
  | '/generate'
301
  | '/history'
 
323
  fileRoutesByTo: FileRoutesByTo
324
  to:
325
  | '/'
326
+ | '/$'
327
  | '/analytics'
328
  | '/bank'
329
+ | '/dashboard'
330
  | '/forgot-password'
331
  | '/generate'
332
  | '/history'
 
353
  id:
354
  | '__root__'
355
  | '/'
356
+ | '/$'
357
  | '/admin'
358
  | '/analytics'
359
  | '/bank'
360
+ | '/dashboard'
361
  | '/forgot-password'
362
  | '/generate'
363
  | '/history'
 
386
  }
387
  export interface RootRouteChildren {
388
  IndexRoute: typeof IndexRoute
389
+ SplatRoute: typeof SplatRoute
390
  AdminRoute: typeof AdminRouteWithChildren
391
  AnalyticsRoute: typeof AnalyticsRoute
392
  BankRoute: typeof BankRoute
393
+ DashboardRoute: typeof DashboardRoute
394
  ForgotPasswordRoute: typeof ForgotPasswordRoute
395
  GenerateRoute: typeof GenerateRoute
396
  HistoryRoute: typeof HistoryRoute
 
494
  preLoaderRoute: typeof ForgotPasswordRouteImport
495
  parentRoute: typeof rootRouteImport
496
  }
497
+ '/dashboard': {
498
+ id: '/dashboard'
499
+ path: '/dashboard'
500
+ fullPath: '/dashboard'
501
+ preLoaderRoute: typeof DashboardRouteImport
502
+ parentRoute: typeof rootRouteImport
503
+ }
504
  '/bank': {
505
  id: '/bank'
506
  path: '/bank'
 
522
  preLoaderRoute: typeof AdminRouteImport
523
  parentRoute: typeof rootRouteImport
524
  }
525
+ '/$': {
526
+ id: '/$'
527
+ path: '/$'
528
+ fullPath: '/$'
529
+ preLoaderRoute: typeof SplatRouteImport
530
+ parentRoute: typeof rootRouteImport
531
+ }
532
  '/': {
533
  id: '/'
534
  path: '/'
 
661
 
662
  const rootRouteChildren: RootRouteChildren = {
663
  IndexRoute: IndexRoute,
664
+ SplatRoute: SplatRoute,
665
  AdminRoute: AdminRouteWithChildren,
666
  AnalyticsRoute: AnalyticsRoute,
667
  BankRoute: BankRoute,
668
+ DashboardRoute: DashboardRoute,
669
  ForgotPasswordRoute: ForgotPasswordRoute,
670
  GenerateRoute: GenerateRoute,
671
  HistoryRoute: HistoryRoute,
apps/web/src/routes/$.tsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { routeShell } from "@/lib/route-shell";
3
+ import { NotFoundPage } from "@/components/NotFoundPage";
4
+
5
+ export const Route = createFileRoute("/$")({
6
+ staticData: routeShell.public,
7
+ component: NotFoundPage,
8
+ });
apps/web/src/routes/__root.tsx CHANGED
@@ -9,6 +9,7 @@ import { ThemeProvider } from "@/components/theme-provider";
9
  import { useSidebar } from "@/hooks/use-sidebar";
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
11
  import { ErrorFallback } from "@/components/ErrorFallback";
 
12
  import type { trpc } from "@/utils/trpc";
13
 
14
  function SkipLink() {
@@ -29,6 +30,14 @@ export interface RouterAppContext {
29
  queryClient: QueryClient;
30
  }
31
 
 
 
 
 
 
 
 
 
32
  export const Route = createRootRouteWithContext<RouterAppContext>()({
33
  component: RootComponent,
34
  errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
@@ -82,18 +91,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
82
 
83
  function RootComponent() {
84
  const { collapsed } = useSidebar();
85
- const matches = useRouterState({ select: (s) => s.matches });
86
- const isFullScreen = matches.some(
87
- (m) =>
88
- m.routeId === "/package/$id/take" ||
89
- m.routeId === "/package/$id/attempt/$attemptId" ||
90
- m.routeId === "/login" ||
91
- m.routeId === "/setup-avatar" ||
92
- m.routeId === "/verify-email" ||
93
- m.routeId === "/forgot-password" ||
94
- m.routeId.startsWith("/admin"),
95
- );
96
- const isLanding = matches.some((m) => m.routeId === "/landing");
97
 
98
  return (
99
  <>
@@ -105,11 +103,11 @@ function RootComponent() {
105
  disableTransitionOnChange
106
  storageKey="labas-theme"
107
  >
108
- {isFullScreen ? (
109
  <div className="h-screen bg-background text-on-surface flex flex-col overflow-y-auto relative">
110
  <Outlet />
111
  </div>
112
- ) : isLanding ? (
113
  <div className="min-h-screen bg-background text-on-surface relative">
114
  <Outlet />
115
  </div>
 
9
  import { useSidebar } from "@/hooks/use-sidebar";
10
  import { GlobalGenerationProgress } from "@/components/generate/GlobalGenerationProgress";
11
  import { ErrorFallback } from "@/components/ErrorFallback";
12
+ import type { RouteShell } from "@/lib/route-shell";
13
  import type { trpc } from "@/utils/trpc";
14
 
15
  function SkipLink() {
 
30
  queryClient: QueryClient;
31
  }
32
 
33
+ function resolveShell(matches: { staticData?: unknown }[]): RouteShell {
34
+ for (let i = matches.length - 1; i >= 0; i--) {
35
+ const shell = (matches[i]?.staticData as { shell?: RouteShell } | undefined)?.shell;
36
+ if (shell) return shell;
37
+ }
38
+ return "app";
39
+ }
40
+
41
  export const Route = createRootRouteWithContext<RouterAppContext>()({
42
  component: RootComponent,
43
  errorComponent: ({ error, reset }) => <ErrorFallback error={error} reset={reset} />,
 
91
 
92
  function RootComponent() {
93
  const { collapsed } = useSidebar();
94
+ const shell = useRouterState({ select: (s) => resolveShell(s.matches) });
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  return (
97
  <>
 
103
  disableTransitionOnChange
104
  storageKey="labas-theme"
105
  >
106
+ {shell === "fullscreen" ? (
107
  <div className="h-screen bg-background text-on-surface flex flex-col overflow-y-auto relative">
108
  <Outlet />
109
  </div>
110
+ ) : shell === "public" ? (
111
  <div className="min-h-screen bg-background text-on-surface relative">
112
  <Outlet />
113
  </div>
apps/web/src/routes/admin.tsx CHANGED
@@ -3,8 +3,10 @@ import { useQuery } from "@tanstack/react-query";
3
  import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
6
 
7
  export const Route = createFileRoute("/admin")({
 
8
  component: AdminLayout,
9
  beforeLoad: async () => {
10
  const session = await authClient.getSession();
@@ -45,7 +47,7 @@ function AdminLayout() {
45
  <MaterialIcon name="gpp_bad" className="text-6xl text-[var(--clay-red)]" />
46
  <h1 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Access Denied</h1>
47
  <p className="text-[var(--warm-charcoal)]">Only admins can access this page.</p>
48
- <Link to="/" className="text-[var(--matcha-700)] underline">Back to Dashboard</Link>
49
  </div>
50
  );
51
  }
@@ -55,7 +57,7 @@ function AdminLayout() {
55
  <div className="flex">
56
  <aside className="w-56 shrink-0 min-h-screen border-r border-[var(--oat-border)] bg-[var(--pure-white)] px-4 py-6 flex flex-col">
57
  <div className="mb-6 px-2">
58
- <Link to="/" className="flex items-center gap-2">
59
  <span className="material-symbols-outlined text-[var(--clay-black)]">arrow_back</span>
60
  <span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
61
  </Link>
 
3
  import { authClient } from "@/lib/auth-client";
4
  import { trpc } from "@/utils/trpc";
5
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
6
+ import { routeShell } from "@/lib/route-shell";
7
 
8
  export const Route = createFileRoute("/admin")({
9
+ staticData: routeShell.fullscreen,
10
  component: AdminLayout,
11
  beforeLoad: async () => {
12
  const session = await authClient.getSession();
 
47
  <MaterialIcon name="gpp_bad" className="text-6xl text-[var(--clay-red)]" />
48
  <h1 className="text-2xl font-headline font-bold text-[var(--clay-black)]">Access Denied</h1>
49
  <p className="text-[var(--warm-charcoal)]">Only admins can access this page.</p>
50
+ <Link to="/dashboard" className="text-[var(--matcha-700)] underline">Back to Dashboard</Link>
51
  </div>
52
  );
53
  }
 
57
  <div className="flex">
58
  <aside className="w-56 shrink-0 min-h-screen border-r border-[var(--oat-border)] bg-[var(--pure-white)] px-4 py-6 flex flex-col">
59
  <div className="mb-6 px-2">
60
+ <Link to="/dashboard" className="flex items-center gap-2">
61
  <span className="material-symbols-outlined text-[var(--clay-black)]">arrow_back</span>
62
  <span className="text-sm text-[var(--warm-charcoal)]">Back to App</span>
63
  </Link>
apps/web/src/routes/analytics.tsx CHANGED
@@ -59,7 +59,7 @@ function AnalyticsComponent() {
59
  {/* Header */}
60
  <div className="mb-8">
61
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
62
- <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
63
  Beranda
64
  </Link>
65
  <MaterialIcon name="chevron_right" className="text-xs" />
 
59
  {/* Header */}
60
  <div className="mb-8">
61
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
62
+ <Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
63
  Beranda
64
  </Link>
65
  <MaterialIcon name="chevron_right" className="text-xs" />
apps/web/src/routes/attempt.$id.tsx CHANGED
@@ -168,216 +168,244 @@ function AttemptResultComponent() {
168
  }
169
 
170
  return (
171
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
172
- {/* Breadcrumb */}
173
- <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-6">
174
- <Link to="/packages" className="hover:text-[var(--clay-black)] transition-colors">
175
- Paket
176
- </Link>
177
- <MaterialIcon name="chevron_right" className="text-xs" />
178
- <span className="text-[var(--clay-black)] font-medium">Hasil Latihan</span>
179
- </div>
180
-
181
- {/* Score Header */}
182
- <div className="mb-8">
183
- <h1 className="text-3xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight mb-4">
184
- Hasil Latihan
185
- </h1>
186
 
187
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] overflow-hidden">
188
- <CardContent className="p-6">
189
- <div className="flex flex-col md:flex-row items-center gap-6">
190
- {/* Big Score Circle */}
191
- <div className="w-32 h-32 rounded-full border-4 border-[var(--matcha-500)] flex flex-col items-center justify-center bg-[var(--matcha-100)]">
192
- <span className="text-3xl font-headline font-extrabold text-[var(--matcha-800)]">
193
- {percentage}%
194
- </span>
195
- <span className="text-xs text-[var(--matcha-700)] font-medium">
196
- {questionStats.correct}/{questionStats.total}
197
- </span>
198
  </div>
199
-
200
- <div className="flex-1 space-y-2 text-center md:text-left">
201
- <p className="text-lg font-semibold text-[var(--clay-black)]">
202
- {percentage >= 80
203
- ? "Sempurna!"
204
- : percentage >= 60
205
- ? "Bagus, terus berlatih!"
206
- : percentage >= 40
207
- ? "Perlu latihan lebih"
208
- : "Jangan menyerah, coba lagi!"}
209
  </p>
210
- <div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)] justify-center md:justify-start">
211
- <span className="flex items-center gap-1">
212
- <MaterialIcon name="timer" className="text-sm" />
213
- {formatTime(durationSec)}
214
- </span>
215
- <span className="flex items-center gap-1">
216
- <MaterialIcon name="quiz" className="text-sm" />
217
- {questionStats.total} soal
218
- </span>
219
- <span className="flex items-center gap-1">
220
- <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
221
- {questionStats.correct} benar
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  </span>
223
- <span className="flex items-center gap-1">
224
- <MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
225
- {questionStats.wrong} salah
226
  </span>
227
  </div>
228
 
229
- {/* Package Rating */}
230
- <div className="flex items-center gap-2 justify-center md:justify-start pt-2">
231
- <span className="text-xs text-[var(--warm-charcoal)]">Nilai paket:</span>
232
- <div className="flex gap-0.5">
233
- {[1, 2, 3, 4, 5].map((star) => (
234
- <button
235
- key={star}
236
- onClick={() => ratePackageMutation.mutate({ packageId: attempt.packageId!, score: star })}
237
- aria-label={`Nilai ${star} bintang`}
238
- className="transition-transform hover:scale-110"
239
- >
240
- <MaterialIcon
241
- name={(packageRatingQuery.data?.myRating ?? 0) >= star ? "star" : "star_outline"}
242
- className={`text-lg ${(packageRatingQuery.data?.myRating ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
243
- />
244
- </button>
245
- ))}
246
- </div>
247
- {packageRatingQuery.data?.avgRating && (
248
- <span className="text-xs text-[var(--warm-charcoal)]">
249
- {packageRatingQuery.data.avgRating}/5
 
 
 
 
 
250
  </span>
251
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  </div>
253
  </div>
254
- </div>
255
- </CardContent>
256
- </Card>
257
- </div>
258
 
259
- {/* Section Breakdown */}
260
- <div className="mb-8">
261
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
262
- <div className="space-y-3">
263
- {attempt.sections.map((sec) => {
264
- const secPct = sec.maxScore && sec.maxScore > 0
265
- ? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
266
- : 0;
267
- return (
268
- <Card key={sec.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
269
- <CardContent className="p-4">
270
- <div className="flex items-center justify-between">
271
- <div>
272
- <p className="font-semibold text-[var(--clay-black)]">{sec.title}</p>
273
- <p className="text-sm text-[var(--warm-charcoal)]">
274
- {sec.score ?? 0}/{sec.maxScore ?? 0} benar
275
- </p>
276
- {sec.timeSpentSec != null && (
277
- <p className="text-xs text-[var(--warm-silver)] mt-1 flex items-center gap-1">
278
- <MaterialIcon name="timer" className="text-xs" />
279
- {formatTime(sec.timeSpentSec)}
280
  </p>
281
- )}
282
- </div>
283
- <div className="w-16 h-16 rounded-full border-2 border-[var(--matcha-400)] flex items-center justify-center bg-[var(--matcha-50)]">
284
- <span className="text-lg font-bold text-[var(--matcha-700)]">{secPct}%</span>
 
 
 
 
 
 
285
  </div>
286
- </div>
287
- </CardContent>
288
- </Card>
289
- );
290
- })}
291
  </div>
292
- </div>
293
 
294
- {/* Skill Breakdown */}
295
- <div className="mb-8">
296
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">
297
- Kemampuan per Topik
298
- <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">Klik topik untuk filter soal</span>
299
- </h2>
300
- <SkillBreakdown
301
- questions={allQuestions.map(({ q, ans }) => ({
302
- skillTags: q.skillTags ?? [],
303
- isCorrect: ans?.isCorrect ?? null,
304
- }))}
305
- onSkillClick={toggleSkill}
306
- activeSkills={filterSkills}
307
- />
308
- </div>
309
 
310
- {/* Question Review */}
311
- <div className="mb-8">
312
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">
313
- Pembahasan Soal
314
- {filterStatus !== "all" && (
315
- <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">
316
- ({filteredQuestions.length} soal)
317
- </span>
318
- )}
319
- </h2>
 
 
320
 
321
- <ReviewFilterBar
322
- filterStatus={filterStatus}
323
- setFilterStatus={setFilterStatus}
324
- filterSkills={filterSkills}
325
- setFilterSkills={setFilterSkills}
326
- questions={allQuestions.map(({ q, ans }) => ({
327
- skillTags: q.skillTags ?? [],
328
- isCorrect: ans?.isCorrect,
329
- }))}
330
- markedCount={partialCount}
331
- allExpanded={allExpanded}
332
- onToggleAllExpanded={toggleAllExpanded}
333
- />
334
 
335
- <div className="space-y-3">
336
- {filteredQuestions.map(({ q, secIdx, qIdx, ans }) => (
337
- <QuestionReviewCard
338
- key={q.id}
339
- q={q}
340
- secIdx={secIdx}
341
- qIdx={qIdx}
342
- ans={ans}
343
- userId={session?.user.id}
344
- isExpanded={expandedIds.has(q.id)}
345
- onToggleExpand={() => toggleExpand(q.id)}
346
- />
347
- ))}
348
- {filteredQuestions.length === 0 && (
349
- <div className="text-center py-12">
350
- <MaterialIcon name="search_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
351
- <p className="text-sm text-[var(--warm-charcoal)]">Tidak ada soal yang sesuai filter.</p>
352
- <button
353
- onClick={() => { setFilterStatus("all"); setFilterSkills([]); }}
354
- className="text-sm text-[var(--matcha-600)] font-semibold mt-2 hover:underline"
355
- >
356
- Reset filter
357
- </button>
358
- </div>
359
- )}
 
360
  </div>
361
  </div>
362
-
363
- {/* Actions */}
364
- <div className="flex gap-3">
365
- <Link to="/package/$id/take" params={{ id: attempt.packageId! }}>
366
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
367
- <MaterialIcon name="replay" />
368
- <span className="ml-2">Coba Lagi</span>
369
- </Button>
370
- </Link>
371
- <Link to="/packages">
372
- <Button
373
- variant="outline"
374
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
375
- >
376
- <MaterialIcon name="arrow_back" />
377
- <span className="ml-2">Kembali ke Paket</span>
378
- </Button>
379
- </Link>
380
- </div>
381
  </div>
382
  );
383
  }
 
168
  }
169
 
170
  return (
171
+ <div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
172
+ {/* Sticky header */}
173
+ <div className="sticky top-0 z-20 bg-[var(--warm-cream)]/95 backdrop-blur-sm border-b border-[var(--oat-border)] shadow-sm">
174
+ <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-4 pb-4">
175
+ {/* Breadcrumb */}
176
+ <div className="flex items-center gap-2 text-xs text-[var(--warm-charcoal)] mb-3">
177
+ <Link to="/packages" className="hover:text-[var(--clay-black)] transition-colors">
178
+ Paket
179
+ </Link>
180
+ <MaterialIcon name="chevron_right" className="text-[10px]" />
181
+ <span className="text-[var(--clay-black)] font-medium">Hasil Latihan</span>
182
+ </div>
 
 
 
183
 
184
+ {/* Title + score summary + actions */}
185
+ <div className="flex flex-wrap items-center justify-between gap-3">
186
+ <div className="flex items-center gap-3 min-w-0">
187
+ <div className={`w-12 h-12 rounded-full border-2 flex items-center justify-center shrink-0 font-headline font-extrabold text-sm ${
188
+ percentage >= 80
189
+ ? "border-[var(--matcha-500)] bg-[var(--matcha-100)] text-[var(--matcha-800)]"
190
+ : percentage >= 60
191
+ ? "border-[var(--lemon-500)] bg-[var(--lemon-100)] text-[var(--lemon-800)]"
192
+ : "border-[var(--pomegranate-400)] bg-[var(--pomegranate-50)] text-[var(--pomegranate-700)]"
193
+ }`}>
194
+ {percentage}%
195
  </div>
196
+ <div>
197
+ <h1 className="text-lg font-headline font-extrabold text-[var(--clay-black)] leading-tight">
198
+ Hasil Latihan
199
+ </h1>
200
+ <p className="text-xs text-[var(--warm-charcoal)]">
201
+ {questionStats.correct}/{questionStats.total} benar · {formatTime(durationSec)}
 
 
 
 
202
  </p>
203
+ </div>
204
+ </div>
205
+
206
+ {/* Actions */}
207
+ <div className="flex items-center gap-2 shrink-0">
208
+ <Link to="/package/$id/take" params={{ id: attempt.packageId! }}>
209
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
210
+ <MaterialIcon name="replay" />
211
+ <span className="ml-1.5">Coba Lagi</span>
212
+ </Button>
213
+ </Link>
214
+ <Link to="/packages">
215
+ <Button
216
+ variant="outline"
217
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
218
+ >
219
+ <MaterialIcon name="arrow_back" />
220
+ <span className="ml-1.5 hidden sm:inline">Kembali</span>
221
+ </Button>
222
+ </Link>
223
+ </div>
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-8">
229
+ {/* Score Card */}
230
+ <div className="mb-8">
231
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] overflow-hidden">
232
+ <CardContent className="p-6">
233
+ <div className="flex flex-col md:flex-row items-center gap-6">
234
+ {/* Big Score Circle */}
235
+ <div className="w-32 h-32 rounded-full border-4 border-[var(--matcha-500)] flex flex-col items-center justify-center bg-[var(--matcha-100)]">
236
+ <span className="text-3xl font-headline font-extrabold text-[var(--matcha-800)]">
237
+ {percentage}%
238
  </span>
239
+ <span className="text-xs text-[var(--matcha-700)] font-medium">
240
+ {questionStats.correct}/{questionStats.total}
 
241
  </span>
242
  </div>
243
 
244
+ <div className="flex-1 space-y-2 text-center md:text-left">
245
+ <p className="text-lg font-semibold text-[var(--clay-black)]">
246
+ {percentage >= 80
247
+ ? "Sempurna!"
248
+ : percentage >= 60
249
+ ? "Bagus, terus berlatih!"
250
+ : percentage >= 40
251
+ ? "Perlu latihan lebih"
252
+ : "Jangan menyerah, coba lagi!"}
253
+ </p>
254
+ <div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)] justify-center md:justify-start">
255
+ <span className="flex items-center gap-1">
256
+ <MaterialIcon name="timer" className="text-sm" />
257
+ {formatTime(durationSec)}
258
+ </span>
259
+ <span className="flex items-center gap-1">
260
+ <MaterialIcon name="quiz" className="text-sm" />
261
+ {questionStats.total} soal
262
+ </span>
263
+ <span className="flex items-center gap-1">
264
+ <MaterialIcon name="check_circle" className="text-sm text-[var(--matcha-600)]" />
265
+ {questionStats.correct} benar
266
+ </span>
267
+ <span className="flex items-center gap-1">
268
+ <MaterialIcon name="cancel" className="text-sm text-[var(--pomegranate-400)]" />
269
+ {questionStats.wrong} salah
270
  </span>
271
+ </div>
272
+
273
+ {/* Package Rating */}
274
+ <div className="flex items-center gap-2 justify-center md:justify-start pt-2">
275
+ <span className="text-xs text-[var(--warm-charcoal)]">Nilai paket:</span>
276
+ <div className="flex gap-0.5">
277
+ {[1, 2, 3, 4, 5].map((star) => (
278
+ <button
279
+ key={star}
280
+ onClick={() => ratePackageMutation.mutate({ packageId: attempt.packageId!, score: star })}
281
+ aria-label={`Nilai ${star} bintang`}
282
+ className="p-1 transition-transform hover:scale-110 cursor-pointer"
283
+ >
284
+ <MaterialIcon
285
+ name={(packageRatingQuery.data?.myRating ?? 0) >= star ? "star" : "star_outline"}
286
+ className={`text-xl ${(packageRatingQuery.data?.myRating ?? 0) >= star ? "text-[var(--lemon-500)]" : "text-[var(--oat-border)]"}`}
287
+ />
288
+ </button>
289
+ ))}
290
+ </div>
291
+ {packageRatingQuery.data?.avgRating && (
292
+ <span className="text-xs text-[var(--warm-charcoal)]">
293
+ {packageRatingQuery.data.avgRating}/5
294
+ </span>
295
+ )}
296
+ </div>
297
  </div>
298
  </div>
299
+ </CardContent>
300
+ </Card>
301
+ </div>
 
302
 
303
+ {/* Section Breakdown */}
304
+ <div className="mb-8">
305
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-4">Per Section</h2>
306
+ <div className="space-y-3">
307
+ {attempt.sections.map((sec) => {
308
+ const secPct = sec.maxScore && sec.maxScore > 0
309
+ ? Math.round(((sec.score ?? 0) / sec.maxScore) * 100)
310
+ : 0;
311
+ return (
312
+ <Card key={sec.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
313
+ <CardContent className="p-4">
314
+ <div className="flex items-center justify-between">
315
+ <div>
316
+ <p className="font-semibold text-[var(--clay-black)]">{sec.title}</p>
317
+ <p className="text-sm text-[var(--warm-charcoal)]">
318
+ {sec.score ?? 0}/{sec.maxScore ?? 0} benar
 
 
 
 
 
319
  </p>
320
+ {sec.timeSpentSec != null && (
321
+ <p className="text-xs text-[var(--warm-silver)] mt-1 flex items-center gap-1">
322
+ <MaterialIcon name="timer" className="text-xs" />
323
+ {formatTime(sec.timeSpentSec)}
324
+ </p>
325
+ )}
326
+ </div>
327
+ <div className="w-16 h-16 rounded-full border-2 border-[var(--matcha-400)] flex items-center justify-center bg-[var(--matcha-50)]">
328
+ <span className="text-lg font-bold text-[var(--matcha-700)]">{secPct}%</span>
329
+ </div>
330
  </div>
331
+ </CardContent>
332
+ </Card>
333
+ );
334
+ })}
335
+ </div>
336
  </div>
 
337
 
338
+ {/* Skill Breakdown */}
339
+ <div className="mb-8">
340
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-1">
341
+ Kemampuan per Topik
342
+ </h2>
343
+ <p className="text-sm text-[var(--warm-charcoal)] mb-4">Klik topik untuk filter soal</p>
344
+ <SkillBreakdown
345
+ questions={allQuestions.map(({ q, ans }) => ({
346
+ skillTags: q.skillTags ?? [],
347
+ isCorrect: ans?.isCorrect ?? null,
348
+ }))}
349
+ onSkillClick={toggleSkill}
350
+ activeSkills={filterSkills}
351
+ />
352
+ </div>
353
 
354
+ {/* Question Review */}
355
+ <div className="mb-8">
356
+ <div className="flex items-baseline gap-2 mb-4">
357
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
358
+ Pembahasan Soal
359
+ </h2>
360
+ {filterStatus !== "all" && (
361
+ <span className="text-sm text-[var(--warm-charcoal)]">
362
+ {filteredQuestions.length} soal
363
+ </span>
364
+ )}
365
+ </div>
366
 
367
+ <ReviewFilterBar
368
+ filterStatus={filterStatus}
369
+ setFilterStatus={setFilterStatus}
370
+ filterSkills={filterSkills}
371
+ setFilterSkills={setFilterSkills}
372
+ questions={allQuestions.map(({ q, ans }) => ({
373
+ skillTags: q.skillTags ?? [],
374
+ isCorrect: ans?.isCorrect,
375
+ }))}
376
+ markedCount={partialCount}
377
+ allExpanded={allExpanded}
378
+ onToggleAllExpanded={toggleAllExpanded}
379
+ />
380
 
381
+ <div className="space-y-3">
382
+ {filteredQuestions.map(({ q, secIdx, qIdx, ans }) => (
383
+ <QuestionReviewCard
384
+ key={q.id}
385
+ q={q}
386
+ secIdx={secIdx}
387
+ qIdx={qIdx}
388
+ ans={ans}
389
+ userId={session?.user.id}
390
+ isExpanded={expandedIds.has(q.id)}
391
+ onToggleExpand={() => toggleExpand(q.id)}
392
+ />
393
+ ))}
394
+ {filteredQuestions.length === 0 && (
395
+ <div className="text-center py-12">
396
+ <MaterialIcon name="search_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
397
+ <p className="text-sm text-[var(--warm-charcoal)]">Tidak ada soal yang sesuai filter.</p>
398
+ <button
399
+ onClick={() => { setFilterStatus("all"); setFilterSkills([]); }}
400
+ className="text-sm text-[var(--matcha-600)] font-semibold mt-2 hover:underline cursor-pointer"
401
+ >
402
+ Reset filter
403
+ </button>
404
+ </div>
405
+ )}
406
+ </div>
407
  </div>
408
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  </div>
410
  );
411
  }
apps/web/src/routes/dashboard.tsx ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { routeShell } from "@/lib/route-shell";
6
+ import { trpc } from "@/utils/trpc";
7
+ import { Button } from "@labas/ui/components/button";
8
+ import { Card, CardContent } from "@labas/ui/components/card";
9
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
+ import { TourGuide } from "@/components/TourGuide";
11
+ import { DonationModal } from "@/components/DonationModal";
12
+
13
+ export const Route = createFileRoute("/dashboard")({
14
+ staticData: routeShell.app,
15
+ component: HomeComponent,
16
+ beforeLoad: async () => {
17
+ const session = await authClient.getSession();
18
+ if (!session.data) {
19
+ throw redirect({ to: "/login" });
20
+ }
21
+ if (!session.data.user.emailVerified) {
22
+ throw redirect({ to: "/verify-email", search: { email: session.data.user.email } });
23
+ }
24
+ if (!session.data.user.image) {
25
+ throw redirect({ to: "/setup-avatar" });
26
+ }
27
+ return { session };
28
+ },
29
+ });
30
+
31
+ function formatDateShort(dateStr: string | Date | null) {
32
+ if (!dateStr) return "-";
33
+ const d = new Date(dateStr);
34
+ return d.toLocaleDateString("id-ID", { day: "numeric", month: "short" });
35
+ }
36
+
37
+ function HomeComponent() {
38
+ const { session } = Route.useRouteContext();
39
+ const [donationModalOpen, setDonationModalOpen] = useState(false);
40
+ const [donationTrigger, setDonationTrigger] = useState<"exam" | "generate" | null>(null);
41
+
42
+ useEffect(() => {
43
+ const prompt = localStorage.getItem("pendingDonationPrompt") as "exam" | "generate" | null;
44
+ const lastPromptStr = localStorage.getItem("lastDonationPromptTime");
45
+ const lastPromptTime = lastPromptStr ? parseInt(lastPromptStr, 10) : 0;
46
+
47
+ // 24 hours cooldown
48
+ const COOLDOWN = 24 * 60 * 60 * 1000;
49
+ const now = Date.now();
50
+
51
+ if (prompt) {
52
+ localStorage.removeItem("pendingDonationPrompt");
53
+
54
+ if (now - lastPromptTime > COOLDOWN) {
55
+ setDonationTrigger(prompt);
56
+ setDonationModalOpen(true);
57
+ localStorage.setItem("lastDonationPromptTime", now.toString());
58
+ }
59
+ }
60
+ }, []);
61
+
62
+ const overview = useQuery(trpc.stats.overview.queryOptions());
63
+ const recentAttempts = useQuery(
64
+ trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
65
+ );
66
+ const featured = useQuery(
67
+ trpc.admin.listFeatured.queryOptions(),
68
+ );
69
+
70
+ const stats = overview.data;
71
+ const attempts = recentAttempts.data?.attempts ?? [];
72
+ const featuredPkg = featured.data?.packages ?? [];
73
+ const featuredQ = featured.data?.questions ?? [];
74
+ const hasFeatured = featuredPkg.length > 0 || featuredQ.length > 0;
75
+
76
+ const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
77
+
78
+ return (
79
+ <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)]">
80
+ <DonationModal
81
+ isOpen={donationModalOpen}
82
+ onOpenChange={setDonationModalOpen}
83
+ triggerAction={donationTrigger}
84
+ />
85
+ {/* Header */}
86
+ <section className="mb-8">
87
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
88
+ Halo, {session.data?.user.name ?? "Pengguna"}!
89
+ </h1>
90
+ <p className="text-lg text-[var(--warm-charcoal)] mt-1">
91
+ {isNewUser ? "Siap mulai belajar? Pilih langkah di bawah ini." : "Lanjutkan latihanmu!"}
92
+ </p>
93
+ </section>
94
+
95
+ {/* Quick Start Banner for New Users */}
96
+ {isNewUser && (
97
+ <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)]">
98
+ <CardContent className="p-6 md:p-8">
99
+ <div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-8">
100
+ <div className="shrink-0">
101
+ <div className="h-16 w-16 bg-[var(--clay-black)] rounded-[var(--radius-2xl)] flex items-center justify-center">
102
+ <MaterialIcon name="rocket_launch" className="text-3xl text-[var(--pure-white)]" />
103
+ </div>
104
+ </div>
105
+ <div className="flex-1">
106
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-1">
107
+ Selamat Datang di Labas
108
+ </h2>
109
+ <p className="text-sm text-[var(--warm-charcoal)] mb-4">
110
+ Platform latihan ujian bahasa berbasis AI. Ikuti 3 langkah mudah untuk memulai:
111
+ </p>
112
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
113
+ <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">
114
+ <div className="h-9 w-9 bg-[var(--matcha-300)] rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
115
+ <span className="text-xs font-black text-[var(--matcha-800)]">1</span>
116
+ </div>
117
+ <div className="flex-1 min-w-0">
118
+ <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--matcha-800)]">Generate Soal</p>
119
+ <p className="text-xs text-[var(--warm-charcoal)]">Buat soal dengan AI</p>
120
+ </div>
121
+ <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--matcha-800)]" />
122
+ </Link>
123
+ <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">
124
+ <div className="h-9 w-9 bg-[var(--slushie-500)]/30 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
125
+ <span className="text-xs font-black text-[var(--slushie-800)]">2</span>
126
+ </div>
127
+ <div className="flex-1 min-w-0">
128
+ <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--slushie-800)]">Buat Paket</p>
129
+ <p className="text-xs text-[var(--warm-charcoal)]">Kumpulkan soal latihan</p>
130
+ </div>
131
+ <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--slushie-800)]" />
132
+ </Link>
133
+ <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">
134
+ <div className="h-9 w-9 bg-[var(--lemon-400)]/50 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
135
+ <span className="text-xs font-black text-[var(--lemon-800)]">3</span>
136
+ </div>
137
+ <div className="flex-1 min-w-0">
138
+ <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--lemon-800)]">Mulai Latihan</p>
139
+ <p className="text-xs text-[var(--warm-charcoal)]">Kerjakan paket soal</p>
140
+ </div>
141
+ <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--lemon-800)]" />
142
+ </Link>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </CardContent>
147
+ </Card>
148
+ )}
149
+
150
+ {/* Quick Stats */}
151
+ <div data-tour="dashboard-stats" className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-10">
152
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
153
+ <CardContent className="p-5">
154
+ <div className="flex items-center gap-3">
155
+ <div className="h-11 w-11 bg-[var(--matcha-300)] rounded-[var(--radius-lg)] flex items-center justify-center">
156
+ <MaterialIcon name="assignment" className="text-lg text-[var(--matcha-800)]" />
157
+ </div>
158
+ <div>
159
+ <p className="text-sm text-[var(--warm-charcoal)]">Latihan Selesai</p>
160
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
161
+ {stats?.completedAttempts ?? 0}
162
+ </p>
163
+ </div>
164
+ </div>
165
+ </CardContent>
166
+ </Card>
167
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
168
+ <CardContent className="p-5">
169
+ <div className="flex items-center gap-3">
170
+ <div className="h-11 w-11 bg-[var(--slushie-500)] rounded-[var(--radius-lg)] flex items-center justify-center">
171
+ <MaterialIcon name="timer" className="text-lg text-[var(--clay-black)]" />
172
+ </div>
173
+ <div>
174
+ <p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
175
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
176
+ {stats ? `${Math.round(stats.totalTimeSpentSec / 60)}m` : "0m"}
177
+ </p>
178
+ </div>
179
+ </div>
180
+ </CardContent>
181
+ </Card>
182
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
183
+ <CardContent className="p-5">
184
+ <div className="flex items-center gap-3">
185
+ <div className="h-11 w-11 bg-[var(--lemon-400)] rounded-[var(--radius-lg)] flex items-center justify-center">
186
+ <MaterialIcon name="quiz" className="text-lg text-[var(--lemon-800)]" />
187
+ </div>
188
+ <div>
189
+ <p className="text-sm text-[var(--warm-charcoal)]">Soal Terjawab</p>
190
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
191
+ {stats?.totalQuestionsAnswered ?? 0}
192
+ </p>
193
+ </div>
194
+ </div>
195
+ </CardContent>
196
+ </Card>
197
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
198
+ <CardContent className="p-5">
199
+ <div className="flex items-center gap-3">
200
+ <div className="h-11 w-11 bg-[var(--pomegranate-400)]/30 rounded-[var(--radius-lg)] flex items-center justify-center">
201
+ <MaterialIcon name="trending_up" className="text-lg text-[var(--pomegranate-600)]" />
202
+ </div>
203
+ <div>
204
+ <p className="text-sm text-[var(--warm-charcoal)]">Akurasi</p>
205
+ <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
206
+ {stats ? `${stats.overallAccuracyPct}%` : "-"}
207
+ </p>
208
+ </div>
209
+ </div>
210
+ </CardContent>
211
+ </Card>
212
+ </div>
213
+
214
+ {/* Quick Actions */}
215
+ <div className="flex flex-col sm:flex-row gap-3 mb-10">
216
+ <Link to="/generate" className="flex-1">
217
+ <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">
218
+ <MaterialIcon name="auto_awesome" className="text-xl" />
219
+ <span className="ml-2">Generate Soal Baru</span>
220
+ </Button>
221
+ </Link>
222
+ <Link to="/packages" className="flex-1">
223
+ <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">
224
+ <MaterialIcon name="folder" className="text-xl" />
225
+ <span className="ml-2">Mulai Latihan</span>
226
+ </Button>
227
+ </Link>
228
+ <Link to="/analytics" className="flex-1">
229
+ <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">
230
+ <MaterialIcon name="analytics" className="text-xl" />
231
+ <span className="ml-2">Lihat Analitik</span>
232
+ </Button>
233
+ </Link>
234
+ </div>
235
+
236
+ {/* Editor's Pick */}
237
+ {hasFeatured && (
238
+ <section className="mb-10">
239
+ <div className="flex items-center justify-between mb-4">
240
+ <div className="flex items-center gap-2">
241
+ <MaterialIcon name="star" className="text-[var(--sunbeam-600)]" />
242
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
243
+ Editor's Pick
244
+ </h2>
245
+ </div>
246
+ <Link to="/packages" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
247
+ Lihat Semua
248
+ </Link>
249
+ </div>
250
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
251
+ {featuredPkg.slice(0, 3).map((pkg) => (
252
+ <Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
253
+ <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)]">
254
+ <CardContent className="p-5">
255
+ <div className="flex items-start justify-between gap-2 mb-3">
256
+ <span className="px-2 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold shrink-0">
257
+ {pkg.examTypeName}
258
+ </span>
259
+ <span className="text-xs text-[var(--warm-silver)] whitespace-nowrap">
260
+ {pkg.usageCount}x digunakan
261
+ </span>
262
+ </div>
263
+ <h3 className="font-headline text-base font-bold text-[var(--clay-black)] mb-1 line-clamp-2">
264
+ {pkg.title}
265
+ </h3>
266
+ {pkg.description && (
267
+ <p className="text-xs text-[var(--warm-charcoal)] line-clamp-2 mb-3">
268
+ {pkg.description}
269
+ </p>
270
+ )}
271
+ <div className="flex items-center justify-between pt-3 border-t border-[var(--oat-border)]">
272
+ <div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
273
+ <span className="flex items-center gap-1">
274
+ <MaterialIcon name="quiz" className="text-xs" />
275
+ {pkg.totalQuestions}
276
+ </span>
277
+ <span className="flex items-center gap-1">
278
+ <MaterialIcon name="folder" className="text-xs" />
279
+ {pkg.totalSections}
280
+ </span>
281
+ {pkg.estimatedDurationMin && (
282
+ <span className="flex items-center gap-1">
283
+ <MaterialIcon name="timer" className="text-xs" />
284
+ {pkg.estimatedDurationMin}m
285
+ </span>
286
+ )}
287
+ </div>
288
+ <MaterialIcon name="play_circle" className="text-xl text-[var(--matcha-600)]" />
289
+ </div>
290
+ </CardContent>
291
+ </Card>
292
+ </Link>
293
+ ))}
294
+ </div>
295
+ </section>
296
+ )}
297
+
298
+ {/* Recent Attempts */}
299
+ {attempts.length > 0 && (
300
+ <section className="mb-10">
301
+ <div className="flex items-center justify-between mb-4">
302
+ <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
303
+ Latihan Terakhir
304
+ </h2>
305
+ <Link to="/history" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
306
+ Lihat Semua
307
+ </Link>
308
+ </div>
309
+ <div className="space-y-3">
310
+ {attempts.map((a) => {
311
+ const pct =
312
+ a.maxScore && a.maxScore > 0 && a.totalScore != null
313
+ ? Math.round((a.totalScore / a.maxScore) * 100)
314
+ : null;
315
+ return (
316
+ <Card
317
+ key={a.id}
318
+ className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
319
+ >
320
+ <CardContent className="p-4">
321
+ <div className="flex items-center justify-between gap-4">
322
+ <div className="flex-1 min-w-0">
323
+ <div className="flex items-center gap-2 mb-1">
324
+ {a.status === "completed" ? (
325
+ <MaterialIcon name="check_circle" className="text-[var(--matcha-600)] text-sm" />
326
+ ) : a.status === "abandoned" ? (
327
+ <MaterialIcon name="cancel" className="text-[var(--warm-silver)] text-sm" />
328
+ ) : (
329
+ <MaterialIcon name="hourglass_top" className="text-[var(--slushie-600)] text-sm" />
330
+ )}
331
+ <span className="text-sm font-semibold text-[var(--clay-black)] truncate">
332
+ {a.packageTitle ?? "Paket tidak diketahui"}
333
+ </span>
334
+ {a.examTypeName && (
335
+ <span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold shrink-0">
336
+ {a.examTypeName}
337
+ </span>
338
+ )}
339
+ </div>
340
+ <div className="text-xs text-[var(--warm-charcoal)]">
341
+ {formatDateShort(a.startedAt)}
342
+ </div>
343
+ </div>
344
+ <div className="flex items-center gap-4 shrink-0">
345
+ {pct != null && (
346
+ <div className="text-right">
347
+ <div className="text-lg font-headline font-bold text-[var(--clay-black)]">
348
+ {pct}%
349
+ </div>
350
+ <div className="text-xs text-[var(--warm-charcoal)]">
351
+ {a.totalScore}/{a.maxScore}
352
+ </div>
353
+ </div>
354
+ )}
355
+ {a.status === "completed" && (
356
+ <Link to="/attempt/$id" params={{ id: a.id }}>
357
+ <Button
358
+ variant="outline"
359
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
360
+ >
361
+ Hasil
362
+ </Button>
363
+ </Link>
364
+ )}
365
+ </div>
366
+ </div>
367
+ </CardContent>
368
+ </Card>
369
+ );
370
+ })}
371
+ </div>
372
+ </section>
373
+ )}
374
+
375
+ {/* Feature Cards - simplified, only show if new user */}
376
+ {isNewUser && (
377
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
378
+ <Link to="/bank" className="block">
379
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
380
+ <CardContent className="p-5">
381
+ <div className="flex items-center gap-3 mb-3">
382
+ <div className="h-9 w-9 bg-[var(--slushie-500)] rounded-[var(--radius-md)] flex items-center justify-center">
383
+ <MaterialIcon name="database" className="text-lg text-[var(--clay-black)]" />
384
+ </div>
385
+ <h3 className="font-headline font-bold text-[var(--clay-black)]">Bank Soal</h3>
386
+ </div>
387
+ <p className="text-xs text-[var(--warm-charcoal)]">
388
+ Simpan, publikasikan, dan buat paket dari soal pilihan.
389
+ </p>
390
+ </CardContent>
391
+ </Card>
392
+ </Link>
393
+ <Link to="/history" className="block">
394
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
395
+ <CardContent className="p-5">
396
+ <div className="flex items-center gap-3 mb-3">
397
+ <div className="h-9 w-9 bg-[var(--lemon-400)] rounded-[var(--radius-md)] flex items-center justify-center">
398
+ <MaterialIcon name="history" className="text-lg text-[var(--lemon-800)]" />
399
+ </div>
400
+ <h3 className="font-headline font-bold text-[var(--clay-black)]">Riwayat</h3>
401
+ </div>
402
+ <p className="text-xs text-[var(--warm-charcoal)]">
403
+ Lihat kembali hasil dan progres latihan kamu.
404
+ </p>
405
+ </CardContent>
406
+ </Card>
407
+ </Link>
408
+ <Link to="/settings" className="block">
409
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
410
+ <CardContent className="p-5">
411
+ <div className="flex items-center gap-3 mb-3">
412
+ <div className="h-9 w-9 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
413
+ <MaterialIcon name="settings" className="text-lg text-[var(--warm-charcoal)]" />
414
+ </div>
415
+ <h3 className="font-headline font-bold text-[var(--clay-black)]">Pengaturan</h3>
416
+ </div>
417
+ <p className="text-xs text-[var(--warm-charcoal)]">
418
+ Kelola API key dan preferensi akun kamu.
419
+ </p>
420
+ </CardContent>
421
+ </Card>
422
+ </Link>
423
+ </div>
424
+ )}
425
+
426
+ <TourGuide />
427
+ </div>
428
+ );
429
+ }
apps/web/src/routes/forgot-password.tsx CHANGED
@@ -8,8 +8,10 @@ import { Label } from "@labas/ui/components/label";
8
  import { toast } from "sonner";
9
  import { getErrorMessage } from "@/lib/error-utils";
10
  import { trpc } from "@/utils/trpc";
 
11
 
12
  export const Route = createFileRoute("/forgot-password")({
 
13
  component: RouteComponent,
14
  });
15
 
 
8
  import { toast } from "sonner";
9
  import { getErrorMessage } from "@/lib/error-utils";
10
  import { trpc } from "@/utils/trpc";
11
+ import { routeShell } from "@/lib/route-shell";
12
 
13
  export const Route = createFileRoute("/forgot-password")({
14
+ staticData: routeShell.public,
15
  component: RouteComponent,
16
  });
17
 
apps/web/src/routes/{landing.lazy.tsx → index.lazy.tsx} RENAMED
@@ -1,6 +1,6 @@
1
  import { createLazyFileRoute } from "@tanstack/react-router";
2
  import { LandingPage } from "@/components/routes/LandingPage";
3
 
4
- export const Route = createLazyFileRoute("/landing")({
5
  component: LandingPage,
6
  });
 
1
  import { createLazyFileRoute } from "@tanstack/react-router";
2
  import { LandingPage } from "@/components/routes/LandingPage";
3
 
4
+ export const Route = createLazyFileRoute("/")({
5
  component: LandingPage,
6
  });
apps/web/src/routes/index.tsx CHANGED
@@ -1,397 +1,29 @@
1
- import { useQuery } from "@tanstack/react-query";
2
- import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { authClient } from "@/lib/auth-client";
4
- 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 { TourGuide } from "@/components/TourGuide";
9
 
10
  export const Route = createFileRoute("/")({
11
- component: HomeComponent,
 
 
 
 
 
 
 
 
 
 
 
 
12
  beforeLoad: async () => {
13
- const session = await authClient.getSession();
14
- if (!session.data) {
15
- throw redirect({ to: "/landing" });
 
 
 
 
16
  }
17
- if (!session.data.user.emailVerified) {
18
- throw redirect({ to: "/verify-email", search: { email: session.data.user.email } });
19
- }
20
- if (!session.data.user.image) {
21
- throw redirect({ to: "/setup-avatar" });
22
- }
23
- return { session };
24
  },
25
  });
26
-
27
- function formatDateShort(dateStr: string | Date | null) {
28
- if (!dateStr) return "-";
29
- const d = new Date(dateStr);
30
- return d.toLocaleDateString("id-ID", { day: "numeric", month: "short" });
31
- }
32
-
33
- function HomeComponent() {
34
- const { session } = Route.useRouteContext();
35
- const overview = useQuery(trpc.stats.overview.queryOptions());
36
- const recentAttempts = useQuery(
37
- trpc.attempt.myAttempts.queryOptions({ limit: 5, offset: 0 }),
38
- );
39
- const featured = useQuery(
40
- trpc.admin.listFeatured.queryOptions(),
41
- );
42
-
43
- const stats = overview.data;
44
- const attempts = recentAttempts.data?.attempts ?? [];
45
- const featuredPkg = featured.data?.packages ?? [];
46
- const featuredQ = featured.data?.questions ?? [];
47
- const hasFeatured = featuredPkg.length > 0 || featuredQ.length > 0;
48
-
49
- const isNewUser = !stats || (stats.completedAttempts === 0 && stats.totalQuestionsAnswered === 0);
50
-
51
- return (
52
- <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)]">
53
- {/* Header */}
54
- <section className="mb-8">
55
- <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
56
- Halo, {session.data?.user.name ?? "Pengguna"}!
57
- </h1>
58
- <p className="text-lg text-[var(--warm-charcoal)] mt-1">
59
- {isNewUser ? "Siap mulai belajar? Pilih langkah di bawah ini." : "Lanjutkan latihanmu!"}
60
- </p>
61
- </section>
62
-
63
- {/* Quick Start Banner for New Users */}
64
- {isNewUser && (
65
- <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)]">
66
- <CardContent className="p-6 md:p-8">
67
- <div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-8">
68
- <div className="shrink-0">
69
- <div className="h-16 w-16 bg-[var(--clay-black)] rounded-[var(--radius-2xl)] flex items-center justify-center">
70
- <MaterialIcon name="rocket_launch" className="text-3xl text-[var(--pure-white)]" />
71
- </div>
72
- </div>
73
- <div className="flex-1">
74
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)] mb-1">
75
- Selamat Datang di Labas
76
- </h2>
77
- <p className="text-sm text-[var(--warm-charcoal)] mb-4">
78
- Platform latihan ujian bahasa berbasis AI. Ikuti 3 langkah mudah untuk memulai:
79
- </p>
80
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
81
- <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">
82
- <div className="h-9 w-9 bg-[var(--matcha-300)] rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
83
- <span className="text-xs font-black text-[var(--matcha-800)]">1</span>
84
- </div>
85
- <div className="flex-1 min-w-0">
86
- <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--matcha-800)]">Generate Soal</p>
87
- <p className="text-xs text-[var(--warm-charcoal)]">Buat soal dengan AI</p>
88
- </div>
89
- <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--matcha-800)]" />
90
- </Link>
91
- <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">
92
- <div className="h-9 w-9 bg-[var(--slushie-500)]/30 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
93
- <span className="text-xs font-black text-[var(--slushie-800)]">2</span>
94
- </div>
95
- <div className="flex-1 min-w-0">
96
- <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--slushie-800)]">Buat Paket</p>
97
- <p className="text-xs text-[var(--warm-charcoal)]">Kumpulkan soal latihan</p>
98
- </div>
99
- <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--slushie-800)]" />
100
- </Link>
101
- <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">
102
- <div className="h-9 w-9 bg-[var(--lemon-400)]/50 rounded-[var(--radius-md)] flex items-center justify-center shrink-0">
103
- <span className="text-xs font-black text-[var(--lemon-800)]">3</span>
104
- </div>
105
- <div className="flex-1 min-w-0">
106
- <p className="text-sm font-bold text-[var(--clay-black)] group-hover:text-[var(--lemon-800)]">Mulai Latihan</p>
107
- <p className="text-xs text-[var(--warm-charcoal)]">Kerjakan paket soal</p>
108
- </div>
109
- <MaterialIcon name="chevron_right" className="text-sm text-[var(--warm-silver)] group-hover:text-[var(--lemon-800)]" />
110
- </Link>
111
- </div>
112
- </div>
113
- </div>
114
- </CardContent>
115
- </Card>
116
- )}
117
-
118
- {/* Quick Stats */}
119
- <div data-tour="dashboard-stats" className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-10">
120
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
121
- <CardContent className="p-5">
122
- <div className="flex items-center gap-3">
123
- <div className="h-11 w-11 bg-[var(--matcha-300)] rounded-[var(--radius-lg)] flex items-center justify-center">
124
- <MaterialIcon name="assignment" className="text-lg text-[var(--matcha-800)]" />
125
- </div>
126
- <div>
127
- <p className="text-sm text-[var(--warm-charcoal)]">Latihan Selesai</p>
128
- <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
129
- {stats?.completedAttempts ?? 0}
130
- </p>
131
- </div>
132
- </div>
133
- </CardContent>
134
- </Card>
135
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
136
- <CardContent className="p-5">
137
- <div className="flex items-center gap-3">
138
- <div className="h-11 w-11 bg-[var(--slushie-500)] rounded-[var(--radius-lg)] flex items-center justify-center">
139
- <MaterialIcon name="timer" className="text-lg text-[var(--clay-black)]" />
140
- </div>
141
- <div>
142
- <p className="text-sm text-[var(--warm-charcoal)]">Waktu Latihan</p>
143
- <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
144
- {stats ? `${Math.round(stats.totalTimeSpentSec / 60)}m` : "0m"}
145
- </p>
146
- </div>
147
- </div>
148
- </CardContent>
149
- </Card>
150
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
151
- <CardContent className="p-5">
152
- <div className="flex items-center gap-3">
153
- <div className="h-11 w-11 bg-[var(--lemon-400)] rounded-[var(--radius-lg)] flex items-center justify-center">
154
- <MaterialIcon name="quiz" className="text-lg text-[var(--lemon-800)]" />
155
- </div>
156
- <div>
157
- <p className="text-sm text-[var(--warm-charcoal)]">Soal Terjawab</p>
158
- <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
159
- {stats?.totalQuestionsAnswered ?? 0}
160
- </p>
161
- </div>
162
- </div>
163
- </CardContent>
164
- </Card>
165
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
166
- <CardContent className="p-5">
167
- <div className="flex items-center gap-3">
168
- <div className="h-11 w-11 bg-[var(--pomegranate-400)]/30 rounded-[var(--radius-lg)] flex items-center justify-center">
169
- <MaterialIcon name="trending_up" className="text-lg text-[var(--pomegranate-600)]" />
170
- </div>
171
- <div>
172
- <p className="text-sm text-[var(--warm-charcoal)]">Akurasi</p>
173
- <p className="text-xl font-bold text-[var(--clay-black)] font-headline">
174
- {stats ? `${stats.overallAccuracyPct}%` : "-"}
175
- </p>
176
- </div>
177
- </div>
178
- </CardContent>
179
- </Card>
180
- </div>
181
-
182
- {/* Quick Actions */}
183
- <div className="flex flex-col sm:flex-row gap-3 mb-10">
184
- <Link to="/generate" className="flex-1">
185
- <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">
186
- <MaterialIcon name="auto_awesome" className="text-xl" />
187
- <span className="ml-2">Generate Soal Baru</span>
188
- </Button>
189
- </Link>
190
- <Link to="/packages" 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="folder" className="text-xl" />
193
- <span className="ml-2">Mulai Latihan</span>
194
- </Button>
195
- </Link>
196
- <Link to="/analytics" className="flex-1">
197
- <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">
198
- <MaterialIcon name="analytics" className="text-xl" />
199
- <span className="ml-2">Lihat Analitik</span>
200
- </Button>
201
- </Link>
202
- </div>
203
-
204
- {/* Editor's Pick */}
205
- {hasFeatured && (
206
- <section className="mb-10">
207
- <div className="flex items-center justify-between mb-4">
208
- <div className="flex items-center gap-2">
209
- <MaterialIcon name="star" className="text-[var(--sunbeam-600)]" />
210
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
211
- Editor's Pick
212
- </h2>
213
- </div>
214
- <Link to="/packages" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
215
- Lihat Semua
216
- </Link>
217
- </div>
218
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
219
- {featuredPkg.slice(0, 3).map((pkg) => (
220
- <Link key={pkg.id} to="/package/$id" params={{ id: pkg.id }} className="block">
221
- <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)]">
222
- <CardContent className="p-5">
223
- <div className="flex items-start justify-between gap-2 mb-3">
224
- <span className="px-2 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold shrink-0">
225
- {pkg.examTypeName}
226
- </span>
227
- <span className="text-xs text-[var(--warm-silver)] whitespace-nowrap">
228
- {pkg.usageCount}x digunakan
229
- </span>
230
- </div>
231
- <h3 className="font-headline text-base font-bold text-[var(--clay-black)] mb-1 line-clamp-2">
232
- {pkg.title}
233
- </h3>
234
- {pkg.description && (
235
- <p className="text-xs text-[var(--warm-charcoal)] line-clamp-2 mb-3">
236
- {pkg.description}
237
- </p>
238
- )}
239
- <div className="flex items-center justify-between pt-3 border-t border-[var(--oat-border)]">
240
- <div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
241
- <span className="flex items-center gap-1">
242
- <MaterialIcon name="quiz" className="text-xs" />
243
- {pkg.totalQuestions}
244
- </span>
245
- <span className="flex items-center gap-1">
246
- <MaterialIcon name="folder" className="text-xs" />
247
- {pkg.totalSections}
248
- </span>
249
- {pkg.estimatedDurationMin && (
250
- <span className="flex items-center gap-1">
251
- <MaterialIcon name="timer" className="text-xs" />
252
- {pkg.estimatedDurationMin}m
253
- </span>
254
- )}
255
- </div>
256
- <MaterialIcon name="play_circle" className="text-xl text-[var(--matcha-600)]" />
257
- </div>
258
- </CardContent>
259
- </Card>
260
- </Link>
261
- ))}
262
- </div>
263
- </section>
264
- )}
265
-
266
- {/* Recent Attempts */}
267
- {attempts.length > 0 && (
268
- <section className="mb-10">
269
- <div className="flex items-center justify-between mb-4">
270
- <h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
271
- Latihan Terakhir
272
- </h2>
273
- <Link to="/history" className="text-sm text-[var(--matcha-600)] font-semibold hover:underline">
274
- Lihat Semua
275
- </Link>
276
- </div>
277
- <div className="space-y-3">
278
- {attempts.map((a) => {
279
- const pct =
280
- a.maxScore && a.maxScore > 0 && a.totalScore != null
281
- ? Math.round((a.totalScore / a.maxScore) * 100)
282
- : null;
283
- return (
284
- <Card
285
- key={a.id}
286
- className="bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
287
- >
288
- <CardContent className="p-4">
289
- <div className="flex items-center justify-between gap-4">
290
- <div className="flex-1 min-w-0">
291
- <div className="flex items-center gap-2 mb-1">
292
- {a.status === "completed" ? (
293
- <MaterialIcon name="check_circle" className="text-[var(--matcha-600)] text-sm" />
294
- ) : a.status === "abandoned" ? (
295
- <MaterialIcon name="cancel" className="text-[var(--warm-silver)] text-sm" />
296
- ) : (
297
- <MaterialIcon name="hourglass_top" className="text-[var(--slushie-600)] text-sm" />
298
- )}
299
- <span className="text-sm font-semibold text-[var(--clay-black)] truncate">
300
- {a.packageTitle ?? "Paket tidak diketahui"}
301
- </span>
302
- {a.examTypeName && (
303
- <span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold shrink-0">
304
- {a.examTypeName}
305
- </span>
306
- )}
307
- </div>
308
- <div className="text-xs text-[var(--warm-charcoal)]">
309
- {formatDateShort(a.startedAt)}
310
- </div>
311
- </div>
312
- <div className="flex items-center gap-4 shrink-0">
313
- {pct != null && (
314
- <div className="text-right">
315
- <div className="text-lg font-headline font-bold text-[var(--clay-black)]">
316
- {pct}%
317
- </div>
318
- <div className="text-xs text-[var(--warm-charcoal)]">
319
- {a.totalScore}/{a.maxScore}
320
- </div>
321
- </div>
322
- )}
323
- {a.status === "completed" && (
324
- <Link to="/attempt/$id" params={{ id: a.id }}>
325
- <Button
326
- variant="outline"
327
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
328
- >
329
- Hasil
330
- </Button>
331
- </Link>
332
- )}
333
- </div>
334
- </div>
335
- </CardContent>
336
- </Card>
337
- );
338
- })}
339
- </div>
340
- </section>
341
- )}
342
-
343
- {/* Feature Cards - simplified, only show if new user */}
344
- {isNewUser && (
345
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
346
- <Link to="/bank" className="block">
347
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
348
- <CardContent className="p-5">
349
- <div className="flex items-center gap-3 mb-3">
350
- <div className="h-9 w-9 bg-[var(--slushie-500)] rounded-[var(--radius-md)] flex items-center justify-center">
351
- <MaterialIcon name="database" className="text-lg text-[var(--clay-black)]" />
352
- </div>
353
- <h3 className="font-headline font-bold text-[var(--clay-black)]">Bank Soal</h3>
354
- </div>
355
- <p className="text-xs text-[var(--warm-charcoal)]">
356
- Simpan, publikasikan, dan buat paket dari soal pilihan.
357
- </p>
358
- </CardContent>
359
- </Card>
360
- </Link>
361
- <Link to="/history" className="block">
362
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
363
- <CardContent className="p-5">
364
- <div className="flex items-center gap-3 mb-3">
365
- <div className="h-9 w-9 bg-[var(--lemon-400)] rounded-[var(--radius-md)] flex items-center justify-center">
366
- <MaterialIcon name="history" className="text-lg text-[var(--lemon-800)]" />
367
- </div>
368
- <h3 className="font-headline font-bold text-[var(--clay-black)]">Riwayat</h3>
369
- </div>
370
- <p className="text-xs text-[var(--warm-charcoal)]">
371
- Lihat kembali hasil dan progres latihan kamu.
372
- </p>
373
- </CardContent>
374
- </Card>
375
- </Link>
376
- <Link to="/settings" className="block">
377
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] h-full">
378
- <CardContent className="p-5">
379
- <div className="flex items-center gap-3 mb-3">
380
- <div className="h-9 w-9 bg-[var(--oat-light)] rounded-[var(--radius-md)] flex items-center justify-center">
381
- <MaterialIcon name="settings" className="text-lg text-[var(--warm-charcoal)]" />
382
- </div>
383
- <h3 className="font-headline font-bold text-[var(--clay-black)]">Pengaturan</h3>
384
- </div>
385
- <p className="text-xs text-[var(--warm-charcoal)]">
386
- Kelola API key dan preferensi akun kamu.
387
- </p>
388
- </CardContent>
389
- </Card>
390
- </Link>
391
- </div>
392
- )}
393
-
394
- <TourGuide />
395
- </div>
396
- );
397
- }
 
1
+ import { createFileRoute, redirect } from "@tanstack/react-router";
 
2
  import { authClient } from "@/lib/auth-client";
3
+ import { routeShell } from "@/lib/route-shell";
 
 
 
 
4
 
5
  export const Route = createFileRoute("/")({
6
+ staticData: routeShell.public,
7
+ head: () => ({
8
+ meta: [
9
+ { title: "Labas — AI-Powered Exam Practice Platform" },
10
+ { name: "description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
11
+ { property: "og:title", content: "Labas — AI-Powered Exam Practice Platform" },
12
+ { property: "og:description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
13
+ { property: "og:url", content: "https://labas.rogasper.com/" },
14
+ ],
15
+ links: [
16
+ { rel: "canonical", href: "https://labas.rogasper.com/" },
17
+ ],
18
+ }),
19
  beforeLoad: async () => {
20
+ try {
21
+ const session = await authClient.getSession();
22
+ if (session?.data) {
23
+ throw redirect({ to: "/dashboard", replace: true });
24
+ }
25
+ } catch (e) {
26
+ if (e instanceof Response) throw e;
27
  }
 
 
 
 
 
 
 
28
  },
29
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/landing.tsx CHANGED
@@ -1,27 +1,9 @@
1
  import { createFileRoute, redirect } from "@tanstack/react-router";
2
- import { authClient } from "@/lib/auth-client";
3
 
4
  export const Route = createFileRoute("/landing")({
5
- head: () => ({
6
- meta: [
7
- { title: "Labas AI-Powered Exam Practice Platform" },
8
- { name: "description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
9
- { property: "og:title", content: "Labas — AI-Powered Exam Practice Platform" },
10
- { property: "og:description", content: "Generate AI-powered practice questions for JLPT, TOPIK, TOAFL, and more. Practice smarter with adaptive test preparation." },
11
- { property: "og:url", content: "https://labas.rogasper.com/landing" },
12
- ],
13
- links: [
14
- { rel: "canonical", href: "https://labas.rogasper.com/landing" },
15
- ],
16
- }),
17
- beforeLoad: async () => {
18
- try {
19
- const session = await authClient.getSession();
20
- if (session?.data) {
21
- throw redirect({ to: "/", replace: true });
22
- }
23
- } catch (e) {
24
- if (e instanceof Response) throw e;
25
- }
26
  },
27
  });
 
1
  import { createFileRoute, redirect } from "@tanstack/react-router";
2
+ import { routeShell } from "@/lib/route-shell";
3
 
4
  export const Route = createFileRoute("/landing")({
5
+ staticData: routeShell.public,
6
+ beforeLoad: () => {
7
+ throw redirect({ to: "/", replace: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  },
9
  });
apps/web/src/routes/leaderboard.tsx CHANGED
@@ -73,7 +73,7 @@ function LeaderboardPage() {
73
  {/* Header */}
74
  <div className="mb-8">
75
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
76
- <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
77
  Beranda
78
  </Link>
79
  <MaterialIcon name="chevron_right" className="text-xs" />
 
73
  {/* Header */}
74
  <div className="mb-8">
75
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
76
+ <Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
77
  Beranda
78
  </Link>
79
  <MaterialIcon name="chevron_right" className="text-xs" />
apps/web/src/routes/login.tsx CHANGED
@@ -3,8 +3,10 @@ import { useState } from "react";
3
 
4
  import SignInForm from "@/components/sign-in-form";
5
  import SignUpForm from "@/components/sign-up-form";
 
6
 
7
  export const Route = createFileRoute("/login")({
 
8
  component: RouteComponent,
9
  });
10
 
 
3
 
4
  import SignInForm from "@/components/sign-in-form";
5
  import SignUpForm from "@/components/sign-up-form";
6
+ import { routeShell } from "@/lib/route-shell";
7
 
8
  export const Route = createFileRoute("/login")({
9
+ staticData: routeShell.public,
10
  component: RouteComponent,
11
  });
12
 
apps/web/src/routes/me.tsx CHANGED
@@ -55,7 +55,7 @@ function MeComponent() {
55
  {/* Header */}
56
  <div className="mb-8">
57
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
58
- <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
59
  Beranda
60
  </Link>
61
  <MaterialIcon name="chevron_right" className="text-xs" />
 
55
  {/* Header */}
56
  <div className="mb-8">
57
  <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
58
+ <Link to="/dashboard" className="hover:text-[var(--clay-black)] transition-colors">
59
  Beranda
60
  </Link>
61
  <MaterialIcon name="chevron_right" className="text-xs" />
apps/web/src/routes/package.$id.attempt.$attemptId.tsx CHANGED
@@ -6,8 +6,10 @@ import { useTestSession } from "@/hooks/use-test-session";
6
  import { Button } from "@labas/ui/components/button";
7
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
  import { AttemptTestView } from "@/components/test/AttemptTestView";
 
9
 
10
  export const Route = createFileRoute("/package/$id/attempt/$attemptId")({
 
11
  component: ContinueAttemptComponent,
12
  beforeLoad: async () => {
13
  const session = await authClient.getSession();
 
6
  import { Button } from "@labas/ui/components/button";
7
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
8
  import { AttemptTestView } from "@/components/test/AttemptTestView";
9
+ import { routeShell } from "@/lib/route-shell";
10
 
11
  export const Route = createFileRoute("/package/$id/attempt/$attemptId")({
12
+ staticData: routeShell.fullscreen,
13
  component: ContinueAttemptComponent,
14
  beforeLoad: async () => {
15
  const session = await authClient.getSession();
apps/web/src/routes/package.$id.index.tsx CHANGED
@@ -65,73 +65,111 @@ function PackageDetailComponent() {
65
  const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
66
 
67
  return (
68
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-4xl mx-auto bg-[var(--warm-cream)]">
69
- {/* Breadcrumb */}
70
- <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-6">
71
- <Link to="/bank" className="hover:text-[var(--clay-black)] transition-colors">Bank Soal</Link>
72
- <MaterialIcon name="chevron_right" className="text-xs" />
73
- <span className="text-[var(--clay-black)] font-medium">Detail Paket</span>
74
- </div>
 
 
 
75
 
76
- {/* Header */}
77
- <div className="mb-8">
78
- <div className="flex flex-wrap items-start justify-between gap-4 mb-4">
79
- <div>
80
- <h1 className="text-3xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
81
- {pkg.title}
82
- </h1>
83
- {pkg.description && (
84
- <p className="text-[var(--warm-charcoal)] mt-2">{pkg.description}</p>
85
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  </div>
87
- <div className="flex gap-2">
88
- <span className="px-3 py-1.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-sm font-semibold">
89
- {pkg.examTypeName}
 
 
 
 
 
 
 
 
 
 
 
90
  </span>
91
- {pkg.isPublic ? (
92
- <span className="px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-sm font-semibold">
93
- Publik
94
- </span>
95
- ) : (
96
- <span className="px-3 py-1.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-sm font-semibold">
97
- Privat
 
98
  </span>
99
  )}
100
- </div>
101
- </div>
102
-
103
- <div className="flex flex-wrap gap-4 text-sm text-[var(--warm-charcoal)]">
104
- <Link
105
- to="/profile/$userId"
106
- params={{ userId: pkg.creatorUserId }}
107
- className="flex items-center gap-1 hover:text-[var(--clay-black)] transition-colors"
108
- >
109
- <MaterialIcon name="person" className="text-sm" />
110
- {pkg.creatorName ?? "Anonim"}
111
- </Link>
112
- <span className="flex items-center gap-1">
113
- <MaterialIcon name="quiz" className="text-sm" />
114
- {totalQuestions} soal
115
- </span>
116
- <span className="flex items-center gap-1">
117
- <MaterialIcon name="folder" className="text-sm" />
118
- {pkg.totalSections} section
119
- </span>
120
- {pkg.estimatedDurationMin && (
121
  <span className="flex items-center gap-1">
122
- <MaterialIcon name="timer" className="text-sm" />
123
- {pkg.estimatedDurationMin} menit
124
  </span>
125
- )}
126
- <span className="flex items-center gap-1">
127
- <MaterialIcon name="trending_up" className="text-sm" />
128
- {pkg.usageCount}x digunakan
129
- </span>
130
  </div>
131
  </div>
132
 
133
  {/* Sections */}
134
- <div className="space-y-6">
135
  {pkg.sections.map((section) => (
136
  <Card key={section.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
137
  <CardContent className="p-6">
@@ -181,40 +219,6 @@ function PackageDetailComponent() {
181
  ))}
182
  </div>
183
 
184
- {/* Actions */}
185
- <div className="flex gap-3 mt-8">
186
- <Link to="/package/$id/take" params={{ id }}>
187
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
188
- <MaterialIcon name="play_arrow" />
189
- <span className="ml-2">Mulai Latihan</span>
190
- </Button>
191
- </Link>
192
- {pkg.isPublic && (
193
- <Button
194
- variant="outline"
195
- onClick={() => {
196
- const url = `${window.location.origin}/package/${id}`;
197
- navigator.clipboard.writeText(url);
198
- toast.success("Link paket disalin ke clipboard!");
199
- }}
200
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
201
- >
202
- <MaterialIcon name="share" />
203
- <span className="ml-2">Bagikan</span>
204
- </Button>
205
- )}
206
- {isOwner && (
207
- <Button
208
- variant="outline"
209
- onClick={() => setIsEditOpen(true)}
210
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
211
- >
212
- <MaterialIcon name="edit" />
213
- <span className="ml-2">Edit Paket</span>
214
- </Button>
215
- )}
216
- </div>
217
-
218
  {isEditOpen && pkg && (
219
  <EditPackageModal
220
  initialTitle={pkg.title}
 
65
  const totalQuestions = pkg.sections.reduce((sum, sec) => sum + sec.questions.length, 0);
66
 
67
  return (
68
+ <div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
69
+ {/* Sticky header */}
70
+ <div className="sticky top-0 z-20 bg-[var(--warm-cream)]/95 backdrop-blur-sm border-b border-[var(--oat-border)] shadow-sm">
71
+ <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-4 pb-4">
72
+ {/* Breadcrumb */}
73
+ <div className="flex items-center gap-2 text-xs text-[var(--warm-charcoal)] mb-3">
74
+ <Link to="/bank" className="hover:text-[var(--clay-black)] transition-colors">Bank Soal</Link>
75
+ <MaterialIcon name="chevron_right" className="text-[10px]" />
76
+ <span className="text-[var(--clay-black)] font-medium truncate max-w-[240px]">{pkg.title}</span>
77
+ </div>
78
 
79
+ {/* Title row + actions */}
80
+ <div className="flex flex-wrap items-start justify-between gap-3">
81
+ <div className="flex-1 min-w-0">
82
+ <div className="flex flex-wrap items-center gap-2 mb-1">
83
+ <span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
84
+ {pkg.examTypeName}
85
+ </span>
86
+ {pkg.isPublic ? (
87
+ <span className="px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
88
+ Publik
89
+ </span>
90
+ ) : (
91
+ <span className="px-2.5 py-1 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
92
+ Privat
93
+ </span>
94
+ )}
95
+ </div>
96
+ <h1 className="text-2xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight leading-tight">
97
+ {pkg.title}
98
+ </h1>
99
+ {pkg.description && (
100
+ <p className="text-sm text-[var(--warm-charcoal)] mt-1 line-clamp-1">{pkg.description}</p>
101
+ )}
102
+ </div>
103
+
104
+ {/* Action buttons */}
105
+ <div className="flex items-center gap-2 shrink-0">
106
+ <Link to="/package/$id/take" params={{ id }}>
107
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)]">
108
+ <MaterialIcon name="play_arrow" />
109
+ <span className="ml-1.5">Mulai Latihan</span>
110
+ </Button>
111
+ </Link>
112
+ {pkg.isPublic && (
113
+ <Button
114
+ variant="outline"
115
+ onClick={() => {
116
+ const url = `${window.location.origin}/package/${id}`;
117
+ navigator.clipboard.writeText(url);
118
+ toast.success("Link paket disalin ke clipboard!");
119
+ }}
120
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
121
+ >
122
+ <MaterialIcon name="share" />
123
+ <span className="ml-1.5 hidden sm:inline">Bagikan</span>
124
+ </Button>
125
+ )}
126
+ {isOwner && (
127
+ <Button
128
+ variant="outline"
129
+ onClick={() => setIsEditOpen(true)}
130
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
131
+ >
132
+ <MaterialIcon name="edit" />
133
+ <span className="ml-1.5 hidden sm:inline">Edit Paket</span>
134
+ </Button>
135
+ )}
136
+ </div>
137
  </div>
138
+
139
+ {/* Stats row */}
140
+ <div className="flex flex-wrap gap-4 text-xs text-[var(--warm-charcoal)] mt-3">
141
+ <Link
142
+ to="/profile/$userId"
143
+ params={{ userId: pkg.creatorUserId }}
144
+ className="flex items-center gap-1 hover:text-[var(--clay-black)] transition-colors"
145
+ >
146
+ <MaterialIcon name="person" className="text-xs" />
147
+ {pkg.creatorName ?? "Anonim"}
148
+ </Link>
149
+ <span className="flex items-center gap-1">
150
+ <MaterialIcon name="quiz" className="text-xs" />
151
+ {totalQuestions} soal
152
  </span>
153
+ <span className="flex items-center gap-1">
154
+ <MaterialIcon name="folder" className="text-xs" />
155
+ {pkg.totalSections} section
156
+ </span>
157
+ {pkg.estimatedDurationMin && (
158
+ <span className="flex items-center gap-1">
159
+ <MaterialIcon name="timer" className="text-xs" />
160
+ {pkg.estimatedDurationMin} menit
161
  </span>
162
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  <span className="flex items-center gap-1">
164
+ <MaterialIcon name="trending_up" className="text-xs" />
165
+ {pkg.usageCount}x digunakan
166
  </span>
167
+ </div>
 
 
 
 
168
  </div>
169
  </div>
170
 
171
  {/* Sections */}
172
+ <div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-8 space-y-6">
173
  {pkg.sections.map((section) => (
174
  <Card key={section.id} className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
175
  <CardContent className="p-6">
 
219
  ))}
220
  </div>
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  {isEditOpen && pkg && (
223
  <EditPackageModal
224
  initialTitle={pkg.title}
apps/web/src/routes/package.$id.take.tsx CHANGED
@@ -8,8 +8,10 @@ import { Button } from "@labas/ui/components/button";
8
  import { Card, CardContent } from "@labas/ui/components/card";
9
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
  import { AttemptTestView } from "@/components/test/AttemptTestView";
 
11
 
12
  export const Route = createFileRoute("/package/$id/take")({
 
13
  component: TakeTestComponent,
14
  beforeLoad: async () => {
15
  const session = await authClient.getSession();
 
8
  import { Card, CardContent } from "@labas/ui/components/card";
9
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
10
  import { AttemptTestView } from "@/components/test/AttemptTestView";
11
+ import { routeShell } from "@/lib/route-shell";
12
 
13
  export const Route = createFileRoute("/package/$id/take")({
14
+ staticData: routeShell.fullscreen,
15
  component: TakeTestComponent,
16
  beforeLoad: async () => {
17
  const session = await authClient.getSession();
apps/web/src/routes/setup-avatar.tsx CHANGED
@@ -19,12 +19,14 @@ import {
19
  HAIR_COLORS,
20
  type DiceBearLoreleiOptions,
21
  } from "@/lib/avatar-url";
 
22
 
23
  function generateRandomSeeds(count: number): string[] {
24
  return Array.from({ length: count }, () => randomSeed());
25
  }
26
 
27
  export const Route = createFileRoute("/setup-avatar")({
 
28
  component: SetupAvatarComponent,
29
  validateSearch: (
30
  search: Record<string, unknown>,
@@ -38,7 +40,7 @@ export const Route = createFileRoute("/setup-avatar")({
38
  throw redirect({ to: "/login" });
39
  }
40
  if (!search.redirectTo && session.data.user.image) {
41
- throw redirect({ to: "/" });
42
  }
43
  return { session };
44
  },
@@ -86,7 +88,7 @@ function SetupAvatarComponent() {
86
 
87
  const handleSave = useCallback(async () => {
88
  await updateMutation.mutateAsync({ image: avatarUrl });
89
- navigate({ to: redirectTo || "/" });
90
  }, [avatarUrl, updateMutation, navigate, redirectTo]);
91
 
92
  const quickPickUrls = useMemo(
 
19
  HAIR_COLORS,
20
  type DiceBearLoreleiOptions,
21
  } from "@/lib/avatar-url";
22
+ import { routeShell } from "@/lib/route-shell";
23
 
24
  function generateRandomSeeds(count: number): string[] {
25
  return Array.from({ length: count }, () => randomSeed());
26
  }
27
 
28
  export const Route = createFileRoute("/setup-avatar")({
29
+ staticData: routeShell.public,
30
  component: SetupAvatarComponent,
31
  validateSearch: (
32
  search: Record<string, unknown>,
 
40
  throw redirect({ to: "/login" });
41
  }
42
  if (!search.redirectTo && session.data.user.image) {
43
+ throw redirect({ to: "/dashboard" });
44
  }
45
  return { session };
46
  },
 
88
 
89
  const handleSave = useCallback(async () => {
90
  await updateMutation.mutateAsync({ image: avatarUrl });
91
+ navigate({ to: redirectTo || "/dashboard" });
92
  }, [avatarUrl, updateMutation, navigate, redirectTo]);
93
 
94
  const quickPickUrls = useMemo(
apps/web/src/routes/verify-email.tsx CHANGED
@@ -8,6 +8,7 @@ import { toast } from "sonner";
8
  import { getErrorMessage } from "@/lib/error-utils";
9
  import { authClient } from "@/lib/auth-client";
10
  import { trpc } from "@/utils/trpc";
 
11
  import { z } from "zod";
12
 
13
  const searchSchema = z.object({
@@ -15,6 +16,7 @@ const searchSchema = z.object({
15
  });
16
 
17
  export const Route = createFileRoute("/verify-email")({
 
18
  component: RouteComponent,
19
  validateSearch: searchSchema,
20
  beforeLoad: async ({ search }) => {
@@ -102,7 +104,7 @@ function RouteComponent() {
102
  toast.success("Email berhasil diverifikasi!");
103
  const session = await authClient.getSession();
104
  if (session.data) {
105
- navigate({ to: "/" });
106
  } else {
107
  navigate({ to: "/login" });
108
  }
 
8
  import { getErrorMessage } from "@/lib/error-utils";
9
  import { authClient } from "@/lib/auth-client";
10
  import { trpc } from "@/utils/trpc";
11
+ import { routeShell } from "@/lib/route-shell";
12
  import { z } from "zod";
13
 
14
  const searchSchema = z.object({
 
16
  });
17
 
18
  export const Route = createFileRoute("/verify-email")({
19
+ staticData: routeShell.public,
20
  component: RouteComponent,
21
  validateSearch: searchSchema,
22
  beforeLoad: async ({ search }) => {
 
104
  toast.success("Email berhasil diverifikasi!");
105
  const session = await authClient.getSession();
106
  if (session.data) {
107
+ navigate({ to: "/dashboard" });
108
  } else {
109
  navigate({ to: "/login" });
110
  }