rogasper commited on
Commit
af24968
·
1 Parent(s): 4c7b844

feat: implement lazy loading for routes and introduce new pages for bank, generate, landing, packages, and settings. Update routeTree to support lazy loading of components, enhancing performance and user experience. Add necessary validation and session handling for new routes, ensuring secure access to features.

Browse files
apps/web/src/components/routes/BankPage.tsx ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { useQuery, useMutation } from "@tanstack/react-query";
3
+ import { authClient } from "@/lib/auth-client";
4
+ import { trpc, queryClient } from "@/utils/trpc";
5
+ import { EXAM_TYPES, SECTIONS } from "@/lib/exam-constants";
6
+ import { formatLabel } from "@/lib/format";
7
+ import { usePackageBuilder } from "@/hooks/use-package-builder";
8
+ import { useLocalStorageBoolean } from "@/hooks/use-local-storage-boolean";
9
+ import { AutoBundleModal } from "@/components/bank/AutoBundleModal";
10
+ import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
11
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
12
+ import { PageTour, TourHelpButton } from "@/components/TourGuide";
13
+ import { FilterBar } from "@/components/bank/FilterBar";
14
+ import { MobileFilterSheet } from "@/components/bank/MobileFilterSheet";
15
+ import { AdvancedFilters } from "@/components/bank/AdvancedFilters";
16
+ import { SoalBrowser } from "@/components/bank/SoalBrowser";
17
+ import { SectionBrowser } from "@/components/bank/SectionBrowser";
18
+ import { BundleSidebar } from "@/components/bank/BundleSidebar";
19
+ import { toast } from "sonner";
20
+ import type { Step } from "react-joyride";
21
+ import { createFileRoute, redirect } from "@tanstack/react-router";
22
+ import z from "zod";
23
+
24
+ const FILTER_ADVANCED_KEY = "labas-bank-filter-advanced";
25
+
26
+ export const Route = createFileRoute("/bank")({
27
+ validateSearch: z.object({
28
+ mode: z.enum(["soal", "section"]).optional(),
29
+ tab: z.enum(["mine", "public"]).optional(),
30
+ search: z.string().optional(),
31
+ examType: z.string().optional(),
32
+ section: z.string().optional(),
33
+ format: z.string().optional(),
34
+ difficulty: z.coerce.number().optional(),
35
+ visibility: z.enum(["all", "private", "public"]).optional(),
36
+ }).parse,
37
+ beforeLoad: async () => {
38
+ const session = await authClient.getSession();
39
+ if (!session.data) {
40
+ redirect({ to: "/login", throw: true });
41
+ }
42
+ return { session };
43
+ },
44
+ });
45
+
46
+ type QuestionTab = "mine" | "public";
47
+ type Mode = "soal" | "section";
48
+
49
+ export function BankComponent() {
50
+ const search = Route.useSearch();
51
+ const navigate = Route.useNavigate();
52
+ const { data: session } = authClient.useSession();
53
+ const userId = session?.user.id;
54
+
55
+ const mode: Mode = search.mode ?? "soal";
56
+ const tab: QuestionTab = search.tab ?? "public";
57
+ const searchText = search.search ?? "";
58
+ const examType = search.examType ?? "";
59
+ const section = search.section ?? "";
60
+ const format = search.format ?? "";
61
+ const difficulty = search.difficulty;
62
+ const visibilityFilter = search.visibility ?? "all";
63
+
64
+ // ── Infinite scroll state ──
65
+ const [allQuestions, setAllQuestions] = useState<any[]>([]);
66
+ const [offset, setOffset] = useState(0);
67
+ const limit = 12;
68
+ const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
69
+
70
+ // ── Sidebar / Bundle State ──
71
+ const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
72
+ const [bundleSections, setBundleSections] = useState<any[]>([]);
73
+ const [bundleTitle, setBundleTitle] = useState("");
74
+ const [bundleDescription, setBundleDescription] = useState("");
75
+ const [bundleIsPublic, setBundleIsPublic] = useState(false);
76
+
77
+ // ── Modals ──
78
+ const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
79
+ const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
80
+
81
+ // ── Filter UI State ──
82
+ const [isAdvancedOpen, setIsAdvancedOpen] = useLocalStorageBoolean(FILTER_ADVANCED_KEY, false);
83
+ const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false);
84
+
85
+ // ── Data Queries ──
86
+ const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
87
+ ? { isPublic: visibilityFilter === "public" }
88
+ : {};
89
+
90
+ const questionQuery = useQuery(
91
+ trpc.question.list.queryOptions(
92
+ {
93
+ search: searchText || undefined,
94
+ examTypeId: examType || undefined,
95
+ sectionTypeId: section || undefined,
96
+ format: format || undefined,
97
+ difficulty,
98
+ ...(tab === "mine" && userId
99
+ ? { creatorUserId: userId, ...visibilityFilterParam }
100
+ : { isPublic: true }),
101
+ limit,
102
+ offset,
103
+ },
104
+ { enabled: mode === "soal" },
105
+ ),
106
+ );
107
+
108
+ // Reset offset when filters change
109
+ useEffect(() => {
110
+ setOffset(0);
111
+ }, [filterKey]);
112
+
113
+ // Append / replace questions when query data arrives
114
+ useEffect(() => {
115
+ const data = questionQuery.data;
116
+ if (!data) return;
117
+ if (offset === 0) {
118
+ setAllQuestions(data.questions ?? []);
119
+ } else {
120
+ setAllQuestions((prev) => {
121
+ const existingIds = new Set(prev.map((q: any) => q.id));
122
+ const newQs = (data.questions ?? []).filter((q: any) => !existingIds.has(q.id));
123
+ return [...prev, ...newQs];
124
+ });
125
+ }
126
+ }, [questionQuery.data]);
127
+
128
+ const totalQuestions = questionQuery.data?.total ?? 0;
129
+ const hasMore = offset + limit < totalQuestions;
130
+
131
+ const sectionQuery = useQuery(
132
+ trpc.combo.availableSections.queryOptions(
133
+ {
134
+ examTypeId: examType || undefined,
135
+ search: searchText || undefined,
136
+ limit: 50,
137
+ offset: 0,
138
+ },
139
+ { enabled: mode === "section" },
140
+ ),
141
+ );
142
+
143
+ // ── Mutations ──
144
+ const { isPending: isPackagePending, handleAutoBundle } = usePackageBuilder();
145
+
146
+ const createPackage = useMutation(trpc.package.create.mutationOptions());
147
+ const addSection = useMutation(trpc.package.addSection.mutationOptions());
148
+ const addQuestion = useMutation(trpc.package.addQuestion.mutationOptions());
149
+ const createCombo = useMutation(trpc.combo.create.mutationOptions());
150
+
151
+ const togglePublic = useMutation({
152
+ ...trpc.question.togglePublic.mutationOptions(),
153
+ onSuccess: () => questionQuery.refetch(),
154
+ });
155
+
156
+ const deleteQuestion = useMutation({
157
+ ...trpc.question.delete.mutationOptions(),
158
+ onSuccess: () => questionQuery.refetch(),
159
+ });
160
+
161
+ const bulkPublish = useMutation({
162
+ ...trpc.question.bulkPublish.mutationOptions(),
163
+ onSuccess: (data) => {
164
+ questionQuery.refetch();
165
+ if (data.skipped > 0) {
166
+ toast.success(
167
+ `${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
168
+ { description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
169
+ );
170
+ } else {
171
+ toast.success(`${data.updated} soal berhasil dipublikasikan`);
172
+ }
173
+ },
174
+ onError: (err: any) => {
175
+ toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: err.message });
176
+ },
177
+ });
178
+
179
+ // ── Navigation helpers ──
180
+ const setMode = (newMode: Mode) => {
181
+ navigate({
182
+ search: {
183
+ mode: newMode,
184
+ tab: newMode === "soal" ? "mine" : undefined,
185
+ search: "",
186
+ examType: "",
187
+ section: "",
188
+ format: "",
189
+ difficulty: undefined,
190
+ },
191
+ });
192
+ if (newMode === "soal") setBundleSections([]);
193
+ else setBundleQuestions([]);
194
+ };
195
+
196
+ const setSearch = (value: string) =>
197
+ navigate({ search: (prev) => ({ ...prev, search: value }) });
198
+
199
+ const setExamType = (value: string) =>
200
+ navigate({ search: (prev) => ({ ...prev, examType: value }) });
201
+
202
+ const setSection = (value: string) =>
203
+ navigate({ search: (prev) => ({ ...prev, section: value }) });
204
+
205
+ const setFormat = (value: string) =>
206
+ navigate({ search: (prev) => ({ ...prev, format: value }) });
207
+
208
+ const setDifficulty = (value: number | undefined) =>
209
+ navigate({ search: (prev) => ({ ...prev, difficulty: value }) });
210
+
211
+ const setVisibility = (value: "all" | "private" | "public") =>
212
+ navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value }) });
213
+
214
+ const setTab = (newTab: QuestionTab) =>
215
+ navigate({
216
+ search: {
217
+ mode: "soal",
218
+ tab: newTab,
219
+ search: "",
220
+ examType: "",
221
+ section: "",
222
+ format: "",
223
+ difficulty: undefined,
224
+ visibility: undefined,
225
+ },
226
+ });
227
+
228
+ const clearFilters = () =>
229
+ navigate({
230
+ search: (prev) => ({
231
+ ...prev,
232
+ search: "",
233
+ examType: "",
234
+ section: "",
235
+ format: "",
236
+ difficulty: undefined,
237
+ visibility: undefined,
238
+ }),
239
+ });
240
+
241
+ const hasFilters =
242
+ !!searchText || !!examType || !!section || !!format || difficulty !== undefined;
243
+
244
+ // ── Active filter chips data ──
245
+ const activeChips = [
246
+ ...(examType ? [{ key: "examType", label: EXAM_TYPES.find((t) => t.id === examType)?.name ?? examType, onRemove: () => setExamType("") }] : []),
247
+ ...(section ? [{ key: "section", label: SECTIONS.find((s) => s.id === section)?.name ?? section, onRemove: () => setSection("") }] : []),
248
+ ...(format ? [{ key: "format", label: formatLabel(format), onRemove: () => setFormat("") }] : []),
249
+ ...(difficulty !== undefined ? [{ key: "difficulty", label: `Lv.${difficulty}`, onRemove: () => setDifficulty(undefined) }] : []),
250
+ ];
251
+
252
+ // ── Bundle helpers ──
253
+ const lockedExamType = bundleQuestions.length > 0 ? bundleQuestions[0]?.examTypeId : null;
254
+
255
+ const isQuestionInBundle = (qid: string) =>
256
+ bundleQuestions.some((q) => q.id === qid);
257
+
258
+ const isSectionInBundle = (sid: string) =>
259
+ bundleSections.some((s) => s.id === sid);
260
+
261
+ const toggleQuestion = (q: any) => {
262
+ if (lockedExamType && q.examTypeId !== lockedExamType) {
263
+ toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
264
+ return;
265
+ }
266
+ setBundleQuestions((prev) => {
267
+ const exists = prev.find((x) => x.id === q.id);
268
+ if (exists) return prev.filter((x) => x.id !== q.id);
269
+ return [...prev, q];
270
+ });
271
+ };
272
+
273
+ const toggleSection = (s: any) => {
274
+ setBundleSections((prev) => {
275
+ const exists = prev.find((x) => x.id === s.id);
276
+ if (exists) return prev.filter((x) => x.id !== s.id);
277
+ return [...prev, s];
278
+ });
279
+ };
280
+
281
+ const removeFromBundle = (id: string, type: "question" | "section") => {
282
+ if (type === "question") {
283
+ setBundleQuestions((prev) => prev.filter((x) => x.id !== id));
284
+ } else {
285
+ setBundleSections((prev) => prev.filter((x) => x.id !== id));
286
+ }
287
+ };
288
+
289
+ // ── Create handlers ──
290
+ const handleCreateFromQuestions = async () => {
291
+ if (!bundleTitle || bundleQuestions.length === 0) return;
292
+ const first = bundleQuestions[0];
293
+ try {
294
+ const pkg = await createPackage.mutateAsync({
295
+ title: bundleTitle,
296
+ description: bundleDescription,
297
+ examTypeId: first?.examTypeId ?? "",
298
+ isPublic: bundleIsPublic,
299
+ estimatedDurationMin: bundleQuestions.length * 2,
300
+ });
301
+ const sec = await addSection.mutateAsync({
302
+ packageId: pkg.id,
303
+ sectionTypeId: first?.sectionTypeId ?? "READING",
304
+ title: `${first?.sectionTypeName ?? "Reading"} Section`,
305
+ orderIndex: 0,
306
+ });
307
+ for (let i = 0; i < bundleQuestions.length; i++) {
308
+ await addQuestion.mutateAsync({
309
+ sectionId: sec.id,
310
+ questionId: bundleQuestions[i].id,
311
+ orderIndex: i,
312
+ });
313
+ }
314
+ setBundleQuestions([]);
315
+ setBundleTitle("");
316
+ setBundleDescription("");
317
+ } catch (err: any) {
318
+ toast.error("Gagal membuat paket", { description: err.message });
319
+ }
320
+ };
321
+
322
+ const handleCreateFromSections = async () => {
323
+ if (!bundleTitle || bundleSections.length === 0) return;
324
+ try {
325
+ await createCombo.mutateAsync({
326
+ title: bundleTitle,
327
+ description: bundleDescription,
328
+ isPublic: bundleIsPublic,
329
+ sections: bundleSections.map((s, i) => ({
330
+ sourcePackageId: s.packageId,
331
+ sourceSectionId: s.id,
332
+ orderIndex: i,
333
+ })),
334
+ });
335
+ setBundleSections([]);
336
+ setBundleTitle("");
337
+ setBundleDescription("");
338
+ toast.success("Combo paket berhasil dibuat!");
339
+ } catch (err: any) {
340
+ toast.error("Gagal membuat combo", { description: err.message });
341
+ }
342
+ };
343
+
344
+ // ── Auto Bundle ──
345
+ const autoBundleExamType = examType || lockedExamType || null;
346
+ const autoBundleSectionType = section || null;
347
+
348
+ const onAutoBundle = async (data: {
349
+ title: string;
350
+ description: string;
351
+ isPublic: boolean;
352
+ count: number;
353
+ sortOrder: "random" | "difficulty";
354
+ }) => {
355
+ const batchSize = 50;
356
+ const baseInput = {
357
+ search: searchText || undefined,
358
+ examTypeId: examType || undefined,
359
+ sectionTypeId: section || undefined,
360
+ format: format || undefined,
361
+ difficulty,
362
+ ...(tab === "mine" && userId
363
+ ? { creatorUserId: userId }
364
+ : { isPublic: true }),
365
+ limit: batchSize,
366
+ };
367
+ const firstPage = await queryClient.fetchQuery(
368
+ trpc.question.list.queryOptions({ ...baseInput, offset: 0 }),
369
+ );
370
+ const allQuestions = [...(firstPage.questions ?? [])];
371
+ const totalAvailable = firstPage.total ?? allQuestions.length;
372
+ for (let offset = batchSize; offset < totalAvailable; offset += batchSize) {
373
+ const pageData = await queryClient.fetchQuery(
374
+ trpc.question.list.queryOptions({ ...baseInput, offset }),
375
+ );
376
+ allQuestions.push(...(pageData.questions ?? []));
377
+ }
378
+ await handleAutoBundle({
379
+ ...data,
380
+ examTypeId: autoBundleExamType ?? "",
381
+ sectionTypeId: autoBundleSectionType ?? "READING",
382
+ allQuestions,
383
+ });
384
+ setIsAutoBundleOpen(false);
385
+ };
386
+
387
+ // ── Render helpers ──
388
+ const questions = allQuestions;
389
+
390
+ const sections = sectionQuery.data?.sections ?? [];
391
+ const groupedSections = sections.reduce((groups: Record<string, any[]>, s: any) => {
392
+ const key = `${s.examTypeName ?? "Unknown"} — ${s.packageTitle ?? "Untitled"}`;
393
+ if (!groups[key]) groups[key] = [];
394
+ groups[key].push(s);
395
+ return groups;
396
+ }, {} as Record<string, any[]>);
397
+
398
+ const isCreating =
399
+ createPackage.isPending ||
400
+ addSection.isPending ||
401
+ addQuestion.isPending ||
402
+ createCombo.isPending;
403
+
404
+ return (
405
+ <div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
406
+ <FilterBar
407
+ mode={mode}
408
+ tab={tab}
409
+ searchText={searchText}
410
+ examType={examType}
411
+ visibility={visibilityFilter}
412
+ activeChips={activeChips}
413
+ hasFilters={hasFilters}
414
+ isAdvancedOpen={isAdvancedOpen}
415
+ lockedExamType={lockedExamType}
416
+ dataTour="bank-filters"
417
+ onToggleAdvanced={() => setIsAdvancedOpen((v) => !v)}
418
+ onSetMode={setMode}
419
+ onSetTab={setTab}
420
+ onSetSearch={setSearch}
421
+ onSetExamType={setExamType}
422
+ onSetVisibility={setVisibility}
423
+ onClearFilters={clearFilters}
424
+ onOpenMobileSheet={() => setIsMobileSheetOpen(true)}
425
+ advancedFilters={
426
+ <AdvancedFilters
427
+ section={section}
428
+ format={format}
429
+ difficulty={difficulty}
430
+ onSetSection={setSection}
431
+ onSetFormat={setFormat}
432
+ onSetDifficulty={setDifficulty}
433
+ />
434
+ }
435
+ />
436
+
437
+ <div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-6">
438
+ <section className="mb-8">
439
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
440
+ Buat Paket
441
+ </h1>
442
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">
443
+ Pilih soal atau section untuk dibuatkan paket latihan.
444
+ </p>
445
+ <div className="mt-3 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
446
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
447
+ <MaterialIcon name="auto_awesome" className="text-sm" />
448
+ Auto Bundle — biarkan AI pilihkan soal otomatis
449
+ </span>
450
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
451
+ <MaterialIcon name="touch_app" className="text-sm" />
452
+ Manual — pilih sendiri soal satu per satu
453
+ </span>
454
+ </div>
455
+ </section>
456
+
457
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
458
+ <div data-tour="bank-questions" className="lg:col-span-8">
459
+ {mode === "soal" ? (
460
+ <SoalBrowser
461
+ isLoading={questionQuery.isLoading}
462
+ questions={questions}
463
+ hasMore={hasMore}
464
+ isFetchingNextPage={questionQuery.isFetching}
465
+ onLoadMore={() => setOffset((prev) => prev + limit)}
466
+ hasFilters={hasFilters}
467
+ userId={userId}
468
+ lockedExamType={lockedExamType}
469
+ tab={tab}
470
+ filterKey={filterKey}
471
+ isQuestionInBundle={isQuestionInBundle}
472
+ onToggleQuestion={toggleQuestion}
473
+ onOpenDetail={setSelectedQuestion}
474
+ onTogglePublic={(id) => togglePublic.mutate({ id })}
475
+ onDelete={(id) => {
476
+ if (confirm("Yakin mau hapus soal ini?")) deleteQuestion.mutate({ id });
477
+ }}
478
+ onClearFilters={clearFilters}
479
+ onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
480
+ onPublishAllPrivate={(ids) => bulkPublish.mutate({ ids })}
481
+ />
482
+ ) : (
483
+ <SectionBrowser
484
+ isLoading={sectionQuery.isLoading}
485
+ groupedSections={groupedSections}
486
+ isSectionInBundle={isSectionInBundle}
487
+ onToggleSection={toggleSection}
488
+ />
489
+ )}
490
+ </div>
491
+
492
+ <BundleSidebar
493
+ mode={mode}
494
+ bundleQuestions={bundleQuestions}
495
+ bundleSections={bundleSections}
496
+ bundleTitle={bundleTitle}
497
+ bundleDescription={bundleDescription}
498
+ bundleIsPublic={bundleIsPublic}
499
+ isCreating={isCreating}
500
+ autoBundleExamType={autoBundleExamType}
501
+ lockedExamType={lockedExamType}
502
+ onSetTitle={setBundleTitle}
503
+ onSetDescription={setBundleDescription}
504
+ onSetIsPublic={setBundleIsPublic}
505
+ onRemoveFromBundle={removeFromBundle}
506
+ onCreateFromQuestions={handleCreateFromQuestions}
507
+ onCreateFromSections={handleCreateFromSections}
508
+ onOpenAutoBundle={() => setIsAutoBundleOpen(true)}
509
+ />
510
+ </div>
511
+ </div>
512
+
513
+ <MobileFilterSheet
514
+ open={isMobileSheetOpen}
515
+ onOpenChange={setIsMobileSheetOpen}
516
+ section={section}
517
+ format={format}
518
+ difficulty={difficulty}
519
+ activeChips={activeChips}
520
+ onSetSection={setSection}
521
+ onSetFormat={setFormat}
522
+ onSetDifficulty={setDifficulty}
523
+ onClearFilters={clearFilters}
524
+ />
525
+
526
+ {selectedQuestion && (
527
+ <QuestionDetailModal
528
+ question={selectedQuestion}
529
+ onClose={() => setSelectedQuestion(null)}
530
+ isSelected={isQuestionInBundle(selectedQuestion.id)}
531
+ onToggleSelect={() => toggleQuestion(selectedQuestion)}
532
+ isSelectable={true}
533
+ />
534
+ )}
535
+
536
+ {isAutoBundleOpen && autoBundleExamType && (
537
+ <AutoBundleModal
538
+ availableCount={totalQuestions}
539
+ examTypeName={EXAM_TYPES.find((t) => t.id === autoBundleExamType)?.name ?? autoBundleExamType}
540
+ sectionTypeName={SECTIONS.find((s) => s.id === autoBundleSectionType)?.name ?? "Reading"}
541
+ onClose={() => setIsAutoBundleOpen(false)}
542
+ onCreate={onAutoBundle}
543
+ isPending={isPackagePending}
544
+ />
545
+ )}
546
+
547
+ <PageTour
548
+ storageKey={BANK_TOUR_KEY}
549
+ autoDelay={600}
550
+ steps={bankPageSteps}
551
+ />
552
+ <TourHelpButton storageKey={BANK_TOUR_KEY} />
553
+ </div>
554
+ );
555
+ }
556
+
557
+ // ── Bank page tour ──
558
+ const BANK_TOUR_KEY = "labas-page-tour-bank";
559
+ const bankPageSteps: Step[] = [
560
+ {
561
+ target: "[data-tour='bank-filters']",
562
+ title: "Filter & Mode",
563
+ content: "Pilih mode 'Dari Soal' untuk pilih soal satu per satu, atau 'Dari Section' untuk gabung section dari paket yang sudah ada. Filter juga berdasarkan exam type dan kata kunci.",
564
+ spotlightPadding: 8,
565
+ },
566
+ {
567
+ target: "[data-tour='bank-questions']",
568
+ title: "Daftar Soal",
569
+ content: "Semua soal yang sesuai filter ditampilkan di sini. Klik soal untuk melihat detail, atau centang untuk menambahkannya ke paket.",
570
+ spotlightPadding: 8,
571
+ },
572
+ {
573
+ target: "[data-tour='bank-sidebar']",
574
+ title: "Sidebar Paket",
575
+ content: "Soal yang dipilih muncul di sini. Atur judul, deskripsi, dan visibilitas paket. Klik 'Auto Bundle' untuk isi otomatis, atau 'Buat Paket' untuk simpan.",
576
+ spotlightPadding: 8,
577
+ },
578
+ ];
apps/web/src/components/routes/GeneratePage.tsx ADDED
@@ -0,0 +1,770 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import { Link } from "@tanstack/react-router";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { trpc } from "@/utils/trpc";
6
+ import { useApiKeys } from "@/hooks/use-api-key";
7
+ import { useGenerationJobs, type CompletedResult } from "@/hooks/use-generation-jobs";
8
+ import { Button } from "@labas/ui/components/button";
9
+ import {
10
+ Select,
11
+ SelectContent,
12
+ SelectItem,
13
+ SelectTrigger,
14
+ SelectValue,
15
+ } from "@labas/ui/components/select";
16
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
17
+ import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
18
+ import { ResultSection } from "@/components/generate/ResultSection";
19
+ import { PageTour, TourHelpButton } from "@/components/TourGuide";
20
+ import {
21
+ EXAM_TYPES,
22
+ SECTIONS,
23
+ FORMATS,
24
+ TOPICS,
25
+ DIFFICULTIES,
26
+ QUESTION_COUNT_PRESETS,
27
+ } from "@/lib/generate-constants";
28
+ import { getDifficultyLabel } from "@/lib/difficulty-mapping";
29
+ import "flag-icons/css/flag-icons.min.css";
30
+ import type { Step } from "react-joyride";
31
+
32
+ const MAX_PARALLEL = 3;
33
+
34
+
35
+ export function RouteComponent() {
36
+ const { configs, hasConfigs } = useApiKeys();
37
+
38
+ const [selectedKeyId, setSelectedKeyId] = useState<string>(
39
+ configs[0]?.id ?? "",
40
+ );
41
+
42
+ useEffect(() => {
43
+ if (configs.length > 0 && !configs.find((c) => c.id === selectedKeyId)) {
44
+ setSelectedKeyId(configs[0].id);
45
+ }
46
+ }, [configs, selectedKeyId]);
47
+
48
+ const selectedConfig = configs.find((c) => c.id === selectedKeyId);
49
+ const [useFreeCredits, setUseFreeCredits] = useState(false);
50
+
51
+ const myCredit = useQuery(
52
+ trpc.admin.getMyCredit.queryOptions(),
53
+ );
54
+ const hasFreeCredits = myCredit.data?.freeCreditsEnabled === true;
55
+ const tokenBalance = myCredit.data?.tokenBalance ?? 0;
56
+
57
+ const {
58
+ activeCount,
59
+ completedResults,
60
+ isGenerating,
61
+ error,
62
+ addJob,
63
+ removeJob,
64
+ resetAll,
65
+ setError,
66
+ } = useGenerationJobs();
67
+
68
+ const [activeTabIdx, setActiveTabIdx] = useState(0);
69
+ const prevResultsLengthRef = useRef(0);
70
+
71
+ // Auto-scroll to results when they appear
72
+ const resultsRef = useRef<HTMLDivElement>(null);
73
+ useEffect(() => {
74
+ if (completedResults.length > 0 && resultsRef.current) {
75
+ resultsRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
76
+ }
77
+ }, [completedResults.length]);
78
+
79
+ useEffect(() => {
80
+ const prev = prevResultsLengthRef.current;
81
+ prevResultsLengthRef.current = completedResults.length;
82
+
83
+ if (completedResults.length === 0) {
84
+ setActiveTabIdx(0);
85
+ return;
86
+ }
87
+
88
+ if (completedResults.length > prev) {
89
+ setActiveTabIdx(completedResults.length - 1);
90
+ return;
91
+ }
92
+
93
+ if (activeTabIdx >= completedResults.length) {
94
+ setActiveTabIdx(completedResults.length - 1);
95
+ }
96
+ }, [completedResults.length]);
97
+
98
+ const [examType, setExamType] = useState("IELTS");
99
+ const [selectedSections, setSelectedSections] = useState<string[]>(["READING"]);
100
+ const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
101
+ const [difficulty, setDifficulty] = useState(2);
102
+ const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
103
+ const [questionCount, setQuestionCount] = useState(5);
104
+ const [weaknessAlign, setWeaknessAlign] = useState(75);
105
+ const [mode, setMode] = useState<"quick" | "agentic">("quick");
106
+
107
+ const isReadingAndWriting = selectedSections.includes("READING") && selectedSections.includes("WRITING");
108
+
109
+ useEffect(() => {
110
+ setSelectedFormats((prev) => {
111
+ const valid = prev.filter((f) =>
112
+ FORMATS.find((fmt) => fmt.id === f)?.allowedExams.includes(examType),
113
+ );
114
+ if (valid.length === 0) {
115
+ return ["multiple_choice"];
116
+ }
117
+ return valid;
118
+ });
119
+ }, [examType]);
120
+
121
+ useEffect(() => {
122
+ if (isReadingAndWriting) {
123
+ if (questionCount < 20) setQuestionCount(20);
124
+ if (mode === "quick") setMode("agentic");
125
+ }
126
+ }, [isReadingAndWriting]);
127
+
128
+ const generate = useMutation({
129
+ ...trpc.ai.generate.mutationOptions(),
130
+ onSuccess: (data) => {
131
+ addJob(data.jobId);
132
+ setError(null);
133
+ },
134
+ onError: (err) => {
135
+ setError(err.message);
136
+ },
137
+ });
138
+
139
+ const toggleSection = (id: string) => {
140
+ setSelectedSections((prev) => {
141
+ if (prev.includes(id)) {
142
+ if (prev.length === 1) return prev;
143
+ return prev.filter((s) => s !== id);
144
+ }
145
+ return [...prev, id];
146
+ });
147
+ };
148
+
149
+ const toggleFormat = (id: string) => {
150
+ setSelectedFormats((prev) =>
151
+ prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
152
+ );
153
+ };
154
+
155
+ const toggleTopic = (topic: string) => {
156
+ setSelectedTopics((prev) =>
157
+ prev.includes(topic) ? prev.filter((t) => t !== topic) : [...prev, topic],
158
+ );
159
+ };
160
+
161
+ const handleGenerate = () => {
162
+ if (!useFreeCredits && (!hasConfigs || !selectedConfig)) {
163
+ setError("API key belum dikonfigurasi. Tambahkan di Settings atau gunakan kredit gratis.");
164
+ return;
165
+ }
166
+ if (selectedSections.length === 0) {
167
+ setError("Pilih minimal 1 section.");
168
+ return;
169
+ }
170
+ if (selectedFormats.length === 0) {
171
+ setError("Pilih minimal 1 format soal.");
172
+ return;
173
+ }
174
+
175
+ const apiKeyConfig = useFreeCredits
176
+ ? undefined
177
+ : {
178
+ baseUrl: selectedConfig!.baseUrl,
179
+ apiKey: selectedConfig!.apiKey,
180
+ model: selectedConfig!.modelName,
181
+ maxTokens: selectedConfig!.maxTokens ?? 16384,
182
+ };
183
+
184
+ generate.mutate({
185
+ examType: examType as any,
186
+ section: selectedSections[0] as any,
187
+ selectedSections: selectedSections as any,
188
+ formats: selectedFormats as any,
189
+ difficulty: difficulty + 1,
190
+ topics: selectedTopics,
191
+ questionCount,
192
+ mode,
193
+ apiKeyConfig,
194
+ } as any);
195
+ };
196
+
197
+ const sectionSplits = (() => {
198
+ if (mode !== "agentic" || questionCount < 20 || selectedSections.length <= 1) return null;
199
+ const base = Math.floor(questionCount / selectedSections.length);
200
+ const rem = questionCount % selectedSections.length;
201
+ return selectedSections.map((s, i) => ({ section: s, count: base + (i < rem ? 1 : 0) }));
202
+ })();
203
+
204
+ const activeResult = completedResults[activeTabIdx] ?? null;
205
+
206
+ return (
207
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
208
+ {/* Header */}
209
+ <section className="flex flex-col gap-2 relative mb-10">
210
+ <div className="absolute -left-8 -top-8 w-64 h-64 ai-glow pointer-events-none opacity-50" />
211
+ <h1 className="text-4xl md:text-5xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
212
+ AI Exam Generator
213
+ </h1>
214
+ <p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
215
+ Generate soal latihan dengan AI. Pilih exam, section, format, dan topik — sisanya AI yang kerjakan.
216
+ </p>
217
+ <div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
218
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
219
+ <MaterialIcon name="looks_one" className="text-sm" />
220
+ Pilih exam &amp; section
221
+ </span>
222
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
223
+ <MaterialIcon name="looks_two" className="text-sm" />
224
+ Atur jumlah &amp; format
225
+ </span>
226
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] font-medium">
227
+ <MaterialIcon name="looks_3" className="text-sm" />
228
+ Generate &amp; simpan
229
+ </span>
230
+ <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--clay-black)]/10 text-[var(--clay-black)] font-medium">
231
+ <MaterialIcon name="looks_4" className="text-sm" />
232
+ Buat paket dari Bank Soal
233
+ </span>
234
+ </div>
235
+ </section>
236
+
237
+ {!hasConfigs && !useFreeCredits && !hasFreeCredits && (
238
+ <div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
239
+ <MaterialIcon name="warning" />
240
+ <span>API key belum dikonfigurasi.</span>
241
+ <Link to="/settings" className="font-semibold underline">
242
+ Tambahkan di Settings →
243
+ </Link>
244
+ </div>
245
+ )}
246
+
247
+ {!hasConfigs && !useFreeCredits && hasFreeCredits && (
248
+ <div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] text-sm flex items-center gap-3 border-2 border-[var(--matcha-400)]">
249
+ <MaterialIcon name="tips_and_updates" />
250
+ <span>Belum ada API key. Kamu bisa pakai Free Credits!</span>
251
+ <button onClick={() => setUseFreeCredits(true)} className="font-semibold underline">
252
+ Gunakan Free Credits →
253
+ </button>
254
+ </div>
255
+ )}
256
+
257
+ <div className="mb-8 p-5 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
258
+ <div className="flex items-center justify-between mb-3">
259
+ <label className="text-sm font-medium text-[var(--clay-black)]">Generation Mode</label>
260
+ </div>
261
+ <div className="flex items-center gap-4">
262
+ <button
263
+ onClick={() => { setUseFreeCredits(false); }}
264
+ className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
265
+ !useFreeCredits
266
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)]"
267
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
268
+ }`}
269
+ >
270
+ <MaterialIcon name="vpn_key" className="text-sm" />
271
+ BYOK
272
+ </button>
273
+ {hasFreeCredits && (
274
+ <button
275
+ onClick={() => { setUseFreeCredits(true); }}
276
+ className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
277
+ useFreeCredits
278
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)]"
279
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
280
+ }`}
281
+ >
282
+ <MaterialIcon name="stars" className="text-sm" />
283
+ Free Credits
284
+ </button>
285
+ )}
286
+ {useFreeCredits && (
287
+ <Link to="/settings" className="text-xs text-[var(--matcha-600)] underline ml-2">
288
+ Atur BYOK di Settings
289
+ </Link>
290
+ )}
291
+ </div>
292
+
293
+ {useFreeCredits && myCredit.data && (
294
+ <div className="mt-4 pt-4 border-t border-[var(--oat-border)] space-y-2">
295
+ <div className="flex items-center justify-between">
296
+ <span className="text-sm text-[var(--warm-charcoal)]">Token kamu</span>
297
+ <span className={`text-lg font-headline font-bold ${tokenBalance > 0 ? "text-[var(--clay-black)]" : "text-[var(--clay-red)]"}`}>
298
+ {tokenBalance.toLocaleString()}
299
+ </span>
300
+ </div>
301
+ {tokenBalance > 0 && (
302
+ <div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
303
+ <div
304
+ className="h-full bg-[var(--matcha-500)] rounded-full transition-all"
305
+ style={{ width: `${Math.min(100, (tokenBalance / 50000) * 100)}%` }}
306
+ />
307
+ </div>
308
+ )}
309
+ {myCredit.data.cooldownRemaining > 0 && (
310
+ <p className="flex items-center gap-1.5 text-xs text-[var(--sunbeam-800)] bg-[var(--sunbeam-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
311
+ <MaterialIcon name="schedule" className="text-base leading-none shrink-0" />
312
+ <span>
313
+ Cooldown: {myCredit.data.cooldownRemaining} hari lagi untuk auto-refill.
314
+ </span>
315
+ </p>
316
+ )}
317
+ {tokenBalance <= 0 && myCredit.data.cooldownRemaining === 0 && (
318
+ <p className="text-xs text-[var(--matcha-700)] bg-[var(--matcha-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
319
+ Token habis. Auto-refill tersedia saat kamu generate.
320
+ </p>
321
+ )}
322
+ {tokenBalance <= 0 && myCredit.data.cooldownRemaining > 0 && (
323
+ <p className="text-xs text-[var(--clay-red)]/80 bg-[var(--clay-red)]/5 px-3 py-1.5 rounded-[var(--radius-md)]">
324
+ Token habis & dalam cooldown. Gunakan BYOK atau tunggu {myCredit.data.cooldownRemaining} hari.
325
+ </p>
326
+ )}
327
+ </div>
328
+ )}
329
+
330
+ {!useFreeCredits && hasConfigs && (
331
+ <div className="mt-4 pt-4 border-t border-[var(--oat-border)]">
332
+ <label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">Provider / API Key</label>
333
+ <div className="flex gap-3">
334
+ <Select value={selectedKeyId} onValueChange={(v) => v && setSelectedKeyId(v)}>
335
+ <SelectTrigger className="flex-1 h-11">
336
+ <SelectValue>
337
+ {selectedConfig ? `${selectedConfig.name} · ${selectedConfig.modelName}` : "Pilih provider..."}
338
+ </SelectValue>
339
+ </SelectTrigger>
340
+ <SelectContent>
341
+ {configs.map((c) => (
342
+ <SelectItem key={c.id} value={c.id}>
343
+ {c.name} · {c.modelName}
344
+ </SelectItem>
345
+ ))}
346
+ </SelectContent>
347
+ </Select>
348
+ <Link to="/settings">
349
+ <Button variant="outline" size="xl" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover">
350
+ <MaterialIcon name="settings" className="mr-1" />
351
+ Kelola
352
+ </Button>
353
+ </Link>
354
+ </div>
355
+ </div>
356
+ )}
357
+
358
+ {!useFreeCredits && !hasConfigs && !hasFreeCredits && (
359
+ <p className="mt-3 text-xs text-[var(--warm-charcoal)]">
360
+ Tambahkan API key di Settings dahulu.
361
+ </p>
362
+ )}
363
+
364
+ {!useFreeCredits && !hasConfigs && hasFreeCredits && (
365
+ <p className="mt-3 text-xs text-[var(--warm-charcoal)]">
366
+ Belum ada API key?{" "}
367
+ <button onClick={() => setUseFreeCredits(true)} className="text-[var(--matcha-600)] underline">
368
+ Gunakan kredit gratis
369
+ </button>
370
+ {" "}atau tambah di Settings.
371
+ </p>
372
+ )}
373
+
374
+ {useFreeCredits && !hasFreeCredits && (
375
+ <p className="mt-3 text-xs text-[var(--warm-charcoal)]">
376
+ Free credits sedang dinonaktifkan oleh admin.
377
+ </p>
378
+ )}
379
+ </div>
380
+
381
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
382
+ {/* Configuration Panel */}
383
+ <div className="lg:col-span-8 flex flex-col gap-10">
384
+
385
+ {/* Exam Type */}
386
+ <div data-tour="generate-exam-type" className="flex flex-col gap-4">
387
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
388
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
389
+ {EXAM_TYPES.map((t) => (
390
+ <button
391
+ key={t.id}
392
+ onClick={() => setExamType(t.id)}
393
+ className={`flex items-center gap-3 py-4 px-4 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[56px] ${
394
+ examType === t.id
395
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
396
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
397
+ }`}
398
+ >
399
+ <span className={`fi fi-${t.code} w-6 h-4 rounded-sm shadow-sm shrink-0`} />
400
+ {t.name}
401
+ </button>
402
+ ))}
403
+ </div>
404
+ </div>
405
+
406
+ {/* Section Selection — Multi-select */}
407
+ <div data-tour="generate-section" className="flex flex-col gap-4">
408
+ <div className="flex items-center justify-between">
409
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
410
+ <span className="text-xs text-[var(--warm-charcoal)]">
411
+ {selectedSections.length} dipilih
412
+ </span>
413
+ </div>
414
+ <div className="flex flex-wrap gap-3">
415
+ {SECTIONS.map((s) => {
416
+ const isSelected = selectedSections.includes(s.id);
417
+ return (
418
+ <button
419
+ key={s.id}
420
+ onClick={() => toggleSection(s.id)}
421
+ className={`flex items-center gap-2.5 px-5 py-3 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[52px] ${
422
+ isSelected
423
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
424
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
425
+ }`}
426
+ >
427
+ <MaterialIcon
428
+ name={isSelected ? "check_circle" : s.icon}
429
+ className={`text-base shrink-0 ${isSelected ? "text-[var(--matcha-400)]" : ""}`}
430
+ />
431
+ {s.name}
432
+ </button>
433
+ );
434
+ })}
435
+ </div>
436
+ {selectedSections.length > 1 && (
437
+ <p className="text-xs text-[var(--matcha-800)] bg-[var(--matcha-300)]/30 px-3 py-2 rounded-[var(--radius-md)]">
438
+ <MaterialIcon name="tips_and_updates" className="text-xs mr-1 inline" />
439
+ Kamu memilih {selectedSections.length} section. Mode Agentic dengan ≥20 soal akan otomatis membagi soal ke section yang dipilih.
440
+ </p>
441
+ )}
442
+ </div>
443
+
444
+ {/* Question Count */}
445
+ <div data-tour="generate-count" className="flex flex-col gap-4">
446
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">
447
+ Jumlah Soal
448
+ <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
449
+ </label>
450
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
451
+ {QUESTION_COUNT_PRESETS.map((p) => {
452
+ const isDisabled = isReadingAndWriting && (p.value === 5 || p.value === 10);
453
+ return (
454
+ <button
455
+ key={p.value}
456
+ onClick={() => setQuestionCount(p.value)}
457
+ disabled={isDisabled}
458
+ className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover flex flex-col items-center gap-1 min-h-[72px] ${
459
+ isDisabled
460
+ ? "bg-[var(--oat-light)] text-[var(--warm-silver)] cursor-not-allowed opacity-50 border-2 border-[var(--oat-border)]"
461
+ : questionCount === p.value
462
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
463
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
464
+ }`}
465
+ >
466
+ <span>{p.label}</span>
467
+ <span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
468
+ </button>
469
+ );
470
+ })}
471
+ </div>
472
+ <div className="flex items-center gap-3 mt-1">
473
+ <span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
474
+ <input
475
+ type="range"
476
+ min={isReadingAndWriting ? 20 : 1}
477
+ max={40}
478
+ value={questionCount}
479
+ onChange={(e) => setQuestionCount(Number(e.target.value))}
480
+ className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
481
+ />
482
+ <span className="text-xs font-bold text-[var(--clay-black)] w-6 text-right">{questionCount}</span>
483
+ </div>
484
+
485
+ {/* Auto Multi-Section Preview */}
486
+ {sectionSplits && (
487
+ <div className="mt-2 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 border border-[var(--matcha-400)]">
488
+ <div className="flex items-center gap-2 mb-2 text-[var(--matcha-800)] font-semibold text-sm">
489
+ <MaterialIcon name="auto_awesome" className="text-xs" />
490
+ Auto Multi-Section
491
+ </div>
492
+ <p className="text-[var(--matcha-800)]/80 text-xs mb-3">
493
+ Mode Agentic dengan {questionCount} soal akan dibagi ke {sectionSplits.length} section:
494
+ </p>
495
+ <div className="flex flex-wrap gap-2">
496
+ {sectionSplits.map((s) => {
497
+ const sec = SECTIONS.find((sec) => sec.id === s.section);
498
+ return (
499
+ <span
500
+ key={s.section}
501
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--pure-white)] text-[var(--matcha-800)] text-xs font-medium border border-[var(--matcha-400)]"
502
+ >
503
+ <MaterialIcon name={sec?.icon ?? "menu_book"} className="text-[10px]" />
504
+ {sec?.name ?? s.section}: {s.count} soal
505
+ </span>
506
+ );
507
+ })}
508
+ </div>
509
+ </div>
510
+ )}
511
+ </div>
512
+
513
+ {/* Difficulty */}
514
+ <div data-tour="generate-difficulty" className="flex flex-col gap-4">
515
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
516
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
517
+ {DIFFICULTIES.map((d, i) => (
518
+ <button
519
+ key={d}
520
+ onClick={() => setDifficulty(i)}
521
+ className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover min-h-[56px] flex flex-col items-center ${
522
+ difficulty === i
523
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
524
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
525
+ }`}
526
+ >
527
+ <span>{getDifficultyLabel(examType, i + 1)}</span>
528
+ <span className={`text-[10px] mt-0.5 ${difficulty === i ? "text-white/60" : "text-[var(--warm-silver)]"}`}>{d}</span>
529
+ </button>
530
+ ))}
531
+ </div>
532
+ </div>
533
+
534
+ {/* Format Selection */}
535
+ <div data-tour="generate-format" className="flex flex-col gap-4">
536
+ <div className="flex items-center justify-between">
537
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
538
+ <span className="text-xs text-[var(--warm-charcoal)]">
539
+ {selectedFormats.length} dipilih
540
+ </span>
541
+ </div>
542
+ <div className="flex flex-wrap gap-2">
543
+ {FORMATS.filter((f) => f.allowedExams.includes(examType)).map((f) => (
544
+ <button
545
+ key={f.id}
546
+ onClick={() => toggleFormat(f.id)}
547
+ className={`px-4 py-2.5 rounded-full text-sm font-medium flex items-center gap-2 cursor-pointer transition-all clay-hover min-h-[40px] ${
548
+ selectedFormats.includes(f.id)
549
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
550
+ : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
551
+ }`}
552
+ >
553
+ {f.name}
554
+ {selectedFormats.includes(f.id) && (
555
+ <MaterialIcon name="close" className="text-sm" />
556
+ )}
557
+ </button>
558
+ ))}
559
+ </div>
560
+ </div>
561
+
562
+ {/* Topic Focus */}
563
+ <div data-tour="generate-topic" className="flex flex-col gap-4">
564
+ <div className="flex items-center justify-between">
565
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
566
+ <span className="text-xs text-[var(--warm-charcoal)]">
567
+ {selectedTopics.length} dipilih
568
+ </span>
569
+ </div>
570
+ <div className="flex flex-wrap gap-2">
571
+ {selectedTopics.map((topic) => (
572
+ <span
573
+ key={topic}
574
+ onClick={() => toggleTopic(topic)}
575
+ className="px-4 py-2 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] font-medium flex items-center gap-2 cursor-pointer transition-all hover:brightness-95 clay-hover min-h-[40px]"
576
+ >
577
+ {topic} <MaterialIcon name="close" className="text-sm" />
578
+ </span>
579
+ ))}
580
+ {TOPICS.filter((t) => !selectedTopics.includes(t)).map((topic) => (
581
+ <button
582
+ key={topic}
583
+ onClick={() => toggleTopic(topic)}
584
+ className="px-4 py-2 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] font-medium hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)] transition-all clay-hover min-h-[40px]"
585
+ >
586
+ {topic}
587
+ </button>
588
+ ))}
589
+ </div>
590
+ </div>
591
+
592
+ {/* Weakness Alignment */}
593
+ <div data-tour="generate-weakness" className="flex flex-col gap-4">
594
+ <div className="flex justify-between items-end">
595
+ <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
596
+ <span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
597
+ Intelligent Focus
598
+ </span>
599
+ </div>
600
+ <div className="relative py-4">
601
+ <input
602
+ type="range"
603
+ min="0"
604
+ max="100"
605
+ value={weaknessAlign}
606
+ onChange={(e) => setWeaknessAlign(Number(e.target.value))}
607
+ className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
608
+ />
609
+ <div className="flex justify-between mt-4 text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)]">
610
+ <span>Soal Seimbang</span>
611
+ <span>Fokus Kelemahan</span>
612
+ </div>
613
+ </div>
614
+ </div>
615
+ </div>
616
+
617
+ {/* Live Preview Card */}
618
+ <div data-tour="generate-blueprint" className="lg:col-span-4">
619
+ <TestBlueprintCard
620
+ examType={examType}
621
+ selectedSections={selectedSections}
622
+ selectedFormats={selectedFormats}
623
+ questionCount={questionCount}
624
+ weaknessAlign={weaknessAlign}
625
+ mode={mode}
626
+ setMode={setMode}
627
+ activeCount={activeCount}
628
+ maxParallel={MAX_PARALLEL}
629
+ generatePending={generate.isPending}
630
+ hasKey={hasConfigs || useFreeCredits}
631
+ error={error}
632
+ onGenerate={handleGenerate}
633
+ onDismissError={() => setError(null)}
634
+ disableQuick={isReadingAndWriting}
635
+ />
636
+ </div>
637
+ </div>
638
+
639
+ {/* Results with Tabs */}
640
+ <div ref={resultsRef}>
641
+ {completedResults.length > 0 && (
642
+ <div className="mt-12">
643
+ <div className="flex items-center justify-between mb-4">
644
+ <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
645
+ Hasil Generate
646
+ </h2>
647
+ <Button
648
+ variant="ghost"
649
+ size="sm"
650
+ onClick={resetAll}
651
+ className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)]"
652
+ >
653
+ <MaterialIcon name="delete_sweep" className="text-sm mr-1" />
654
+ Bersihkan
655
+ </Button>
656
+ </div>
657
+
658
+ {/* Tab Bar */}
659
+ <div className="flex gap-1 mb-6 border-b border-[var(--oat-border)] overflow-x-auto">
660
+ {completedResults.map((res, idx) => {
661
+ const questions = res.result?.questions ?? [];
662
+ const isActive = idx === activeTabIdx;
663
+ return (
664
+ <button
665
+ key={res.jobId}
666
+ onClick={() => setActiveTabIdx(idx)}
667
+ className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
668
+ isActive
669
+ ? "bg-[var(--pure-white)] text-[var(--clay-black)] border-[var(--clay-black)]"
670
+ : "text-[var(--warm-charcoal)] border-transparent hover:text-[var(--clay-black)] hover:bg-[var(--oat-light)]"
671
+ }`}
672
+ >
673
+ <MaterialIcon
674
+ name={questions.length > 0 ? "check_circle" : "sync"}
675
+ className={`text-sm ${isActive ? "text-[var(--matcha-400)]" : ""} ${questions.length === 0 && !isActive ? "animate-spin" : ""}`}
676
+ />
677
+ <span>
678
+ {res.mode === "agentic" ? "Agentic" : "Quick"}
679
+ </span>
680
+ <span className="text-xs text-[var(--warm-charcoal)]">
681
+ {questions.length} soal
682
+ </span>
683
+ <button
684
+ onClick={(e) => {
685
+ e.stopPropagation();
686
+ removeJob(res.jobId);
687
+ }}
688
+ className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/10 text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors"
689
+ >
690
+ <MaterialIcon name="close" className="text-xs" />
691
+ </button>
692
+ </button>
693
+ );
694
+ })}
695
+ </div>
696
+
697
+ {/* Active Tab Content */}
698
+ {activeResult && (
699
+ <ResultSection
700
+ result={activeResult.result}
701
+ generatedPackageId={activeResult.generatedPackageId}
702
+ onClear={resetAll}
703
+ />
704
+ )}
705
+ </div>
706
+ )}
707
+ </div>
708
+
709
+ <PageTour
710
+ storageKey={GENERATE_TOUR_KEY}
711
+ autoDelay={600}
712
+ steps={generatePageSteps}
713
+ />
714
+ <TourHelpButton storageKey={GENERATE_TOUR_KEY} />
715
+ </div>
716
+ );
717
+ }
718
+
719
+ // ── Generate page tour ──
720
+ const GENERATE_TOUR_KEY = "labas-page-tour-generate";
721
+ const generatePageSteps: Step[] = [
722
+ {
723
+ target: "[data-tour='generate-exam-type']",
724
+ title: "Jenis Ujian",
725
+ content: "Pilih jenis ujian yang ingin kamu latih. Tersedia IELTS, TOEFL, JLPT, HSK, Goethe, TOPIK (Korea), TOAFL (Arab), dan DELE (Spanyol).",
726
+ spotlightPadding: 8,
727
+ },
728
+ {
729
+ target: "[data-tour='generate-section']",
730
+ title: "Section",
731
+ content: "Pilih section yang ingin digenerate. Bisa pilih lebih dari satu. Mode Agentic dengan ≥20 soal otomatis membagi soal ke setiap section.",
732
+ spotlightPadding: 8,
733
+ },
734
+ {
735
+ target: "[data-tour='generate-count']",
736
+ title: "Jumlah Soal",
737
+ content: "Atur jumlah soal yang ingin digenerate via preset atau slider. Maksimal 40 soal per generate.",
738
+ spotlightPadding: 8,
739
+ },
740
+ {
741
+ target: "[data-tour='generate-difficulty']",
742
+ title: "Tingkat Kesulitan",
743
+ content: "Pilih tingkat kesulitan. Label menyesuaikan dengan jenis ujian yang dipilih (misal: N5-N1 untuk JLPT, Band 4.0-8.0 untuk IELTS).",
744
+ spotlightPadding: 8,
745
+ },
746
+ {
747
+ target: "[data-tour='generate-format']",
748
+ title: "Format Soal",
749
+ content: "Pilih format soal (multiple choice, true/false, dll). Format tersedia tergantung exam type yang dipilih.",
750
+ spotlightPadding: 8,
751
+ },
752
+ {
753
+ target: "[data-tour='generate-topic']",
754
+ title: "Topik",
755
+ content: "Pilih topik yang ingin difokuskan. Bisa pilih lebih dari satu topik.",
756
+ spotlightPadding: 8,
757
+ },
758
+ {
759
+ target: "[data-tour='generate-weakness']",
760
+ title: "Intelligent Focus",
761
+ content: "Atur fokus pada kelemahan kamu. AI akan menarget area yang perlu ditingkatkan berdasarkan riwayat jawaban.",
762
+ spotlightPadding: 8,
763
+ },
764
+ {
765
+ target: "[data-tour='generate-blueprint']",
766
+ title: "Test Blueprint & Generate",
767
+ content: "Ringkasan konfigurasi kamu. Pilih mode Quick (cepat) atau Agentic (multi-tahap). Klik 'Generate & Launch' untuk memulai!",
768
+ spotlightPadding: 8,
769
+ },
770
+ ];
apps/web/src/components/routes/LandingPage.tsx ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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);
23
+
24
+ return (
25
+ <div className={`border-b-2 py-6 ${isDark ? 'border-[var(--ube-300)]/30' : 'border-[var(--oat-border)]'} last:border-b-0`}>
26
+ <button
27
+ type="button"
28
+ className="flex w-full items-center justify-between text-left focus:outline-none group"
29
+ onClick={() => setIsOpen(!isOpen)}
30
+ aria-expanded={isOpen}
31
+ >
32
+ <span className={`font-headline font-semibold text-2xl tracking-[-0.64px] transition-colors ${isDark ? 'text-[var(--pure-white)] group-hover:text-[var(--ube-300)]' : 'text-[var(--clay-black)] group-hover:text-[var(--matcha-700)]'}`}>
33
+ {question}
34
+ </span>
35
+ <div className={`w-10 h-10 rounded-full border-2 flex items-center justify-center transition-transform duration-300 ${isOpen ? "rotate-180" : ""} ${isDark ? 'border-[var(--ube-300)]/50 text-[var(--pure-white)] group-hover:bg-[var(--ube-300)]/20' : 'border-[var(--oat-border)] text-[var(--clay-black)] group-hover:bg-[var(--oat-light)]'}`}>
36
+ <MaterialIcon
37
+ name="expand_more"
38
+ className="text-2xl"
39
+ />
40
+ </div>
41
+ </button>
42
+ <div
43
+ className={`overflow-hidden transition-all duration-300 ease-in-out ${isOpen ? "max-h-96 mt-4 opacity-100" : "max-h-0 opacity-0"}`}
44
+ >
45
+ <p className={`text-lg leading-relaxed pr-12 ${isDark ? 'text-[var(--ube-300)]' : 'text-[var(--warm-charcoal)]'}`}>
46
+ {answer}
47
+ </p>
48
+ </div>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export function LandingPage() {
54
+ return (
55
+ <div className="min-h-screen bg-[var(--warm-cream)] flex flex-col font-sans selection:bg-[var(--matcha-300)] selection:text-[var(--clay-black)]">
56
+ {/* Navbar */}
57
+ <nav className="w-full px-6 py-4 md:px-12 lg:px-16 flex items-center justify-between max-w-7xl mx-auto z-50 sticky top-0 bg-[var(--warm-cream)] border-b-2 border-[var(--oat-border)]">
58
+ <div className="flex items-center gap-3">
59
+ <img src="/logo.png" alt="Labas Logo" className="h-10 w-auto object-contain" />
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-4">
63
+ <Link to="/login">
64
+ <Button variant="ghost" className="text-[var(--clay-black)] font-semibold hover:bg-[var(--oat-light)] rounded-[12px] text-lg px-6 h-12">
65
+ Masuk
66
+ </Button>
67
+ </Link>
68
+ <Link to="/login">
69
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--dark-charcoal)] rounded-[24px] h-12 px-8 font-semibold text-lg clay-hover clay-shadow">
70
+ Mulai Gratis
71
+ </Button>
72
+ </Link>
73
+ </div>
74
+ </nav>
75
+
76
+ <main className="flex-1 flex flex-col items-center overflow-x-hidden">
77
+ {/* HUGE HERO SECTION */}
78
+ <section className="w-full px-6 md:px-12 lg:px-16 pt-16 pb-20 md:pt-24 md:pb-32 flex flex-col xl:flex-row items-center justify-between gap-12 lg:gap-16 relative max-w-[1440px] mx-auto">
79
+ <div className="flex-1 text-center xl:text-left space-y-8 z-10 w-full max-w-[800px] xl:max-w-none">
80
+ <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--matcha-300)] border-2 border-[var(--matcha-800)] text-[var(--clay-black)] uppercase-label shadow-sm mx-auto xl:mx-0 transform -rotate-2">
81
+ <MaterialIcon name="auto_awesome" className="text-sm" />
82
+ <span>Didukung oleh AI Generative</span>
83
+ </div>
84
+
85
+ <h1 className="text-[50px] md:text-[70px] lg:text-[85px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] lg:tracking-[-3.2px] leading-[1.0] lg:leading-[0.95] drop-shadow-sm">
86
+ Platform Latihan Bahasa Cerdas.
87
+ </h1>
88
+
89
+ <p className="text-xl md:text-2xl text-[var(--warm-charcoal)] max-w-2xl mx-auto xl:mx-0 leading-relaxed">
90
+ Persiapkan dirimu untuk ujian bahasa asing dengan latihan soal interaktif, mock test realistis, dan AI Generator super cepat.
91
+ </p>
92
+
93
+ <div className="flex flex-col sm:flex-row items-center gap-6 justify-center xl:justify-start pt-6">
94
+ <Link to="/login" className="w-full sm:w-auto">
95
+ <Button className="w-full sm:w-auto bg-[var(--pure-white)] text-[var(--clay-black)] rounded-[24px] h-[64px] px-10 text-xl font-semibold border-2 border-[var(--oat-border)] clay-shadow clay-hover">
96
+ Mulai Latihan Sekarang
97
+ </Button>
98
+ </Link>
99
+ </div>
100
+ </div>
101
+
102
+ {/* MASSIVE HERO IMAGE WITH FLOATING ASSETS - SIDE BY SIDE ON DESKTOP */}
103
+ <div className="flex-1 w-full relative group mt-8 xl:mt-0 flex justify-center xl:justify-end">
104
+ <div className="relative w-full max-w-[650px] lg:max-w-[750px]">
105
+ <img
106
+ src="/hero_img.png"
107
+ alt="Labas Dashboard Preview"
108
+ className="w-full h-auto object-contain transform transition-transform duration-700 group-hover:scale-105 group-hover:-rotate-1 drop-shadow-[0_20px_50px_rgba(0,0,0,0.15)] relative z-10"
109
+ />
110
+ {/* Variatif Floating Elements */}
111
+ <div className="absolute -top-8 -left-8 md:-top-12 md:-left-12 w-32 md:w-40 h-auto z-20 hidden sm:block">
112
+ <img src="/generateai.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
113
+ </div>
114
+ <div className="absolute -bottom-8 -right-8 md:-bottom-12 md:-right-12 w-40 md:w-48 h-auto z-20 hidden sm:block">
115
+ <img src="/mocktest.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
116
+ </div>
117
+ <div className="absolute top-[40%] -left-16 md:-left-24 w-24 md:w-32 h-auto z-0 hidden lg:block opacity-80 blur-[1px]">
118
+ <img src="/progress.png" alt="floating element" className="w-full h-auto drop-shadow-xl" />
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </section>
123
+
124
+ {/* Feature Cards Section */}
125
+ <section id="features" className="w-full bg-[var(--pure-white)] py-32 border-y-2 border-dashed border-[var(--oat-border)]">
126
+ <div className="max-w-7xl mx-auto px-6 md:px-12 lg:px-16">
127
+ <div className="text-center max-w-4xl mx-auto mb-24 space-y-6">
128
+ <h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] leading-tight">
129
+ Fitur Unggulan Labas
130
+ </h2>
131
+ <p className="text-2xl text-[var(--warm-charcoal)]">
132
+ Desain yang elegan, namun kokoh untuk mempercepat kesiapan Anda menghadapi ujian.
133
+ </p>
134
+ </div>
135
+
136
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
137
+ {/* Feature 1 */}
138
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
139
+ <div className="h-56 w-full flex items-center justify-center p-2">
140
+ <img src="/generateai.png" alt="AI Generator" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
141
+ </div>
142
+ <CardContent className="p-6">
143
+ <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
144
+ AI Generator
145
+ </h3>
146
+ <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
147
+ Hasilkan soal latihan baru menggunakan AI. Tentukan topik, level kesulitan, dan format dalam hitungan detik.
148
+ </p>
149
+ </CardContent>
150
+ </Card>
151
+
152
+ {/* Feature 2 */}
153
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
154
+ <div className="h-56 w-full flex items-center justify-center p-2">
155
+ <img src="/latihansoal.png" alt="Latihan Soal" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
156
+ </div>
157
+ <CardContent className="p-6">
158
+ <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
159
+ Latihan Terfokus
160
+ </h3>
161
+ <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
162
+ Akses ribuan soal latihan dari bank soal. Buat paket latihan Anda sendiri dan fokus pada area kelemahan.
163
+ </p>
164
+ </CardContent>
165
+ </Card>
166
+
167
+ {/* Feature 3 */}
168
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
169
+ <div className="h-56 w-full flex items-center justify-center p-2">
170
+ <img src="/mocktest.png" alt="Mock Test" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
171
+ </div>
172
+ <CardContent className="p-6">
173
+ <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
174
+ Simulasi Ujian
175
+ </h3>
176
+ <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
177
+ Simulasikan suasana ujian sesungguhnya dengan batas waktu, antarmuka imersif, dan penilaian instan.
178
+ </p>
179
+ </CardContent>
180
+ </Card>
181
+
182
+ {/* Feature 4 */}
183
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
184
+ <div className="h-56 w-full flex items-center justify-center p-2">
185
+ <img src="/progress.png" alt="Progress Tracking" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
186
+ </div>
187
+ <CardContent className="p-6">
188
+ <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
189
+ Analitik Progres
190
+ </h3>
191
+ <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
192
+ Lacak perkembangan nilai Anda dari waktu ke waktu. Analisis mendalam untuk setiap bagian tes bahasa.
193
+ </p>
194
+ </CardContent>
195
+ </Card>
196
+
197
+ {/* Feature 5 */}
198
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
199
+ <div className="h-56 w-full flex items-center justify-center p-2">
200
+ <img src="/vocabulary.png" alt="Vocabulary" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
201
+ </div>
202
+ <CardContent className="p-6">
203
+ <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
204
+ Kosakata
205
+ </h3>
206
+ <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
207
+ Perkaya kosakata Anda dengan metode cerdas dan pengulangan berkala yang dioptimalkan.
208
+ </p>
209
+ </CardContent>
210
+ </Card>
211
+
212
+ {/* Feature 6 */}
213
+ <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
214
+ <div className="h-56 w-full flex items-center justify-center p-2">
215
+ <img src="/diskusi.png" alt="Diskusi" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
216
+ </div>
217
+ <CardContent className="p-6">
218
+ <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
219
+ Forum Diskusi
220
+ </h3>
221
+ <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
222
+ Diskusikan soal-soal sulit dengan komunitas pembelajar lainnya dan dapatkan penjelasan ahli.
223
+ </p>
224
+ </CardContent>
225
+ </Card>
226
+ </div>
227
+ </div>
228
+ </section>
229
+
230
+ {/* SWATCH ROOM 1: Ube FAQ Section */}
231
+ <section className="w-full bg-[var(--ube-800)] py-32 rounded-t-[40px] mt-[-40px] z-10 relative shadow-[0_-10px_40px_rgba(0,0,0,0.1)] border-t-2 border-[var(--oat-border)]/20">
232
+ <div className="max-w-4xl mx-auto px-6 md:px-12 lg:px-16">
233
+ <div className="text-center mb-16">
234
+ <h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--pure-white)] tracking-[-2.4px] mb-6 leading-tight drop-shadow-sm">
235
+ Pertanyaan Umum
236
+ </h2>
237
+ <p className="text-[var(--ube-300)] text-2xl max-w-2xl mx-auto">
238
+ Temukan jawaban cepat untuk pertanyaan seputar Labas.
239
+ </p>
240
+ </div>
241
+
242
+ <div className="bg-[var(--ube-900)]/40 backdrop-blur-sm border-2 border-[var(--ube-300)]/30 rounded-[32px] p-8 md:p-12 shadow-2xl">
243
+ <FaqItem
244
+ isDark
245
+ question="Apa itu Labas?"
246
+ answer="Labas adalah platform latihan ujian bahasa berbasis AI yang dirancang untuk membantu Anda berlatih dan menguasai bahasa asing melalui simulasi, bank soal interaktif, dan analitik performa."
247
+ />
248
+ <FaqItem
249
+ isDark
250
+ question="Bagaimana cara kerja AI Generator?"
251
+ answer="Fitur AI Generator memungkinkan Anda membuat paket soal baru berdasarkan konteks atau topik tertentu. Anda cukup memasukkan teks acuan, dan AI Agent kami akan memproduksi soal secara otomatis."
252
+ />
253
+ <FaqItem
254
+ isDark
255
+ question="Apakah Labas sepenuhnya gratis?"
256
+ answer="Platform Labas dapat digunakan secara gratis untuk fitur dasar. Untuk fitur generasi soal berbasis AI, kami menggunakan model Bring-Your-Own-Key (BYOK). Anda cukup memasukkan API Key OpenAI Anda."
257
+ />
258
+ <FaqItem
259
+ isDark
260
+ question="Bahasa apa saja yang didukung oleh Labas?"
261
+ answer="Saat ini Labas mendukung latihan untuk berbagai ujian profisiensi bahasa populer seperti Bahasa Inggris (TOEFL, IELTS, TOEIC), Jepang (JLPT), Korea (TOPIK), dan banyak lagi."
262
+ />
263
+ </div>
264
+ </div>
265
+ </section>
266
+
267
+ {/* SWATCH ROOM 2: Matcha CTA Section */}
268
+ <section className="w-full bg-[var(--matcha-800)] py-40 px-6 rounded-t-[40px] mt-[-40px] z-20 relative shadow-[0_-10px_40px_rgba(0,0,0,0.2)] border-t-2 border-[var(--matcha-600)]">
269
+ <div className="max-w-4xl mx-auto text-center space-y-12">
270
+ <h2 className="text-[60px] md:text-[80px] font-headline font-semibold text-[var(--pure-white)] tracking-[-3.2px] leading-[0.95] drop-shadow-md">
271
+ Siap Meningkatkan Skor Anda?
272
+ </h2>
273
+ <p className="text-2xl text-[var(--matcha-300)] max-w-2xl mx-auto">
274
+ Bergabung sekarang dan rasakan perbedaan belajar dengan teknologi yang berpusat pada perkembangan Anda.
275
+ </p>
276
+ <div className="pt-8">
277
+ <Link to="/login" className="inline-block">
278
+ <Button className="bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] rounded-[24px] h-[80px] px-14 text-2xl font-bold clay-shadow clay-hover">
279
+ Daftar Sekarang - Gratis
280
+ </Button>
281
+ </Link>
282
+ </div>
283
+ </div>
284
+ </section>
285
+ </main>
286
+
287
+ {/* Footer */}
288
+ <footer className="w-full bg-[var(--pure-white)] py-12 border-t-2 border-[var(--oat-border)] text-center relative z-30">
289
+ <p className="text-[var(--warm-charcoal)] font-semibold text-lg">
290
+ &copy; {new Date().getFullYear()} Labas. Didesain dengan penuh kehangatan.
291
+ </p>
292
+ </footer>
293
+ </div>
294
+ );
295
+ }
apps/web/src/components/routes/PackagesPage.tsx ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Select,
12
+ SelectContent,
13
+ SelectGroup,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "@labas/ui/components/select";
18
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
19
+ import { GettingStartedCard } from "@/components/GettingStartedCard";
20
+ import { CalloutCard } from "@/components/bank/CalloutCard";
21
+ import { PageTour, TourHelpButton } from "@/components/TourGuide";
22
+ import type { Step } from "react-joyride";
23
+ import { toast } from "sonner";
24
+ import { getErrorMessage } from "@/lib/error-utils";
25
+ import { EXAM_TYPES } from "@/lib/exam-constants";
26
+
27
+ export const Route = createFileRoute("/packages")({
28
+ validateSearch: z.object({
29
+ tab: z.enum(["all", "mine"]).optional(),
30
+ search: z.string().optional(),
31
+ examType: z.string().optional(),
32
+ page: z.coerce.number().optional(),
33
+ visibility: z.enum(["all", "private", "public"]).optional(),
34
+ }).parse,
35
+ beforeLoad: async () => {
36
+ const session = await authClient.getSession();
37
+ if (!session.data) {
38
+ redirect({ to: "/login", throw: true });
39
+ }
40
+ return { session };
41
+ },
42
+ });
43
+
44
+ type Tab = "all" | "mine";
45
+
46
+ export function PackagesComponent() {
47
+ const routerNavigate = useNavigate();
48
+ const search = Route.useSearch();
49
+ const navigate = Route.useNavigate();
50
+ const { data: session } = authClient.useSession();
51
+ const userId = session?.user.id;
52
+
53
+ const tab = search.tab ?? "all";
54
+ const searchText = search.search ?? "";
55
+ const examType = search.examType ?? "";
56
+ const page = search.page ?? 1;
57
+ const visibilityFilter = search.visibility ?? "all";
58
+ const limit = 12;
59
+
60
+ const allQuery = useQuery(
61
+ trpc.package.list.queryOptions(
62
+ {
63
+ isPublic: true,
64
+ examTypeId: examType || undefined,
65
+ search: searchText || undefined,
66
+ limit,
67
+ offset: (page - 1) * limit,
68
+ },
69
+ { enabled: tab === "all" },
70
+ ),
71
+ );
72
+
73
+ const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
74
+ ? { isPublic: visibilityFilter === "public" }
75
+ : {};
76
+
77
+ const mineQuery = useQuery(
78
+ trpc.package.myPackages.queryOptions(
79
+ {
80
+ search: searchText || undefined,
81
+ examTypeId: examType || undefined,
82
+ limit,
83
+ offset: (page - 1) * limit,
84
+ ...visibilityFilterParam,
85
+ },
86
+ { enabled: tab === "mine" },
87
+ ),
88
+ );
89
+
90
+ const query = tab === "all" ? allQuery : mineQuery;
91
+ const packages = query.data?.packages ?? [];
92
+ const total = query.data?.total ?? 0;
93
+ const totalPages = Math.ceil(total / limit);
94
+
95
+ const updateMutation = useMutation(
96
+ trpc.package.update.mutationOptions({
97
+ onSuccess: () => {
98
+ query.refetch();
99
+ },
100
+ }),
101
+ );
102
+
103
+ const togglePublic = (pkgId: string, current: boolean) => {
104
+ updateMutation.mutate({ id: pkgId, isPublic: !current });
105
+ };
106
+
107
+ const bulkPublish = useMutation(
108
+ trpc.package.bulkPublish.mutationOptions({
109
+ onSuccess: (data) => {
110
+ query.refetch();
111
+ setBulkMode(false);
112
+ setSelectedIds(new Set());
113
+ if (data.skipped > 0) {
114
+ toast.success(
115
+ `${data.updated} paket dipublikasikan, ${data.skipped} dilewati`,
116
+ { description: "Beberapa paket bukan milikmu atau sudah tidak tersedia." },
117
+ );
118
+ } else {
119
+ toast.success(`${data.updated} paket berhasil dipublikasikan`);
120
+ }
121
+ },
122
+ onError: (err: unknown) => {
123
+ toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang paket.", { description: getErrorMessage(err) });
124
+ },
125
+ }),
126
+ );
127
+
128
+ // ── Bulk select ──
129
+ const [bulkMode, setBulkMode] = useState(false);
130
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
131
+
132
+ const toggleSelect = (id: string) => {
133
+ setSelectedIds((prev) => {
134
+ const next = new Set(prev);
135
+ if (next.has(id)) next.delete(id);
136
+ else next.add(id);
137
+ return next;
138
+ });
139
+ };
140
+
141
+ const clearSelection = () => setSelectedIds(new Set());
142
+ const selectAll = () => setSelectedIds(new Set(packages.map((p) => p.id)));
143
+
144
+ useEffect(() => {
145
+ setBulkMode(false);
146
+ setSelectedIds(new Set());
147
+ }, [tab, searchText, examType]);
148
+
149
+ const setTab = (newTab: Tab) => {
150
+ navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
151
+ };
152
+
153
+ // ── Private callout state ──
154
+ const [calloutDismissed, setCalloutDismissed] = useState(
155
+ typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
156
+ );
157
+ const privatePackages = packages.filter(
158
+ (p) => !p.isPublic && p.creatorUserId === userId,
159
+ );
160
+
161
+ const handleDismissCallout = () => {
162
+ localStorage.setItem("labas-packages-private-callout-dismissed", "true");
163
+ setCalloutDismissed(true);
164
+ };
165
+
166
+ const handlePublishAllPrivate = () => {
167
+ const ids = privatePackages.map((p) => p.id);
168
+ if (ids.length > 0) bulkPublish.mutate({ ids });
169
+ };
170
+
171
+ const setSearch = (value: string) => {
172
+ navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
173
+ };
174
+
175
+ const setExamType = (value: string) => {
176
+ navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
177
+ };
178
+
179
+ const setVisibility = (value: "all" | "private" | "public") => {
180
+ navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value, page: 1 }) });
181
+ };
182
+
183
+ const setPage = (newPage: number) => {
184
+ navigate({ search: (prev) => ({ ...prev, page: newPage }) });
185
+ };
186
+
187
+ return (
188
+ <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
189
+ <section className="mb-8">
190
+ <div data-tour="packages-header" className="flex items-center justify-between">
191
+ <div>
192
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
193
+ Paket Soal
194
+ </h1>
195
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">
196
+ Kumpulan paket latihan dari komunitas.
197
+ </p>
198
+ </div>
199
+ <Link to="/bank">
200
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] h-11">
201
+ <MaterialIcon name="add" />
202
+ <span className="ml-2 hidden sm:inline">Buat Paket</span>
203
+ </Button>
204
+ </Link>
205
+ </div>
206
+ </section>
207
+
208
+ {/* Getting Started Guide */}
209
+ <GettingStartedCard />
210
+
211
+ {/* Tabs */}
212
+ <div className="flex gap-2 mb-6">
213
+ <button
214
+ onClick={() => setTab("all")}
215
+ className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
216
+ tab === "all"
217
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
218
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
219
+ }`}
220
+ >
221
+ Semua Paket
222
+ </button>
223
+ <button
224
+ onClick={() => setTab("mine")}
225
+ className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
226
+ tab === "mine"
227
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
228
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
229
+ }`}
230
+ >
231
+ Paket Saya
232
+ </button>
233
+ </div>
234
+
235
+ {/* Filters */}
236
+ <div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
237
+ <div className="relative flex-1 max-w-md">
238
+ <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
239
+ <Input
240
+ placeholder="Cari paket..."
241
+ value={searchText}
242
+ onChange={(e) => setSearch(e.target.value)}
243
+ className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
244
+ />
245
+ </div>
246
+ <Select
247
+ value={examType}
248
+ onValueChange={(v: string | null) => setExamType(v ?? "")}
249
+ >
250
+ <SelectTrigger className="w-36">
251
+ <SelectValue placeholder="Semua Ujian" />
252
+ </SelectTrigger>
253
+ <SelectContent>
254
+ <SelectGroup>
255
+ <SelectItem value="">Semua Ujian</SelectItem>
256
+ {EXAM_TYPES.map((t) => (
257
+ <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
258
+ ))}
259
+ </SelectGroup>
260
+ </SelectContent>
261
+ </Select>
262
+ {tab === "mine" && (
263
+ <div className="flex gap-2">
264
+ <VisChip active={visibilityFilter === "all"} onClick={() => setVisibility("all")}>
265
+ <MaterialIcon name="visibility" className="text-xs" />
266
+ Semua
267
+ </VisChip>
268
+ <VisChip active={visibilityFilter === "private"} onClick={() => setVisibility("private")}>
269
+ <MaterialIcon name="lock" className="text-xs" />
270
+ Privat
271
+ </VisChip>
272
+ <VisChip active={visibilityFilter === "public"} onClick={() => setVisibility("public")}>
273
+ <MaterialIcon name="public" className="text-xs" />
274
+ Publik
275
+ </VisChip>
276
+ </div>
277
+ )}
278
+ </div>
279
+
280
+ {/* Bulk toolbar */}
281
+ {tab === "mine" && (
282
+ <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)]">
283
+ {bulkMode ? (
284
+ <>
285
+ <div className="flex items-center gap-3">
286
+ <button
287
+ onClick={clearSelection}
288
+ className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center gap-1"
289
+ >
290
+ <MaterialIcon name="close" className="text-xs" />
291
+ Batalkan ({selectedIds.size})
292
+ </button>
293
+ <button
294
+ onClick={selectAll}
295
+ className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
296
+ >
297
+ <MaterialIcon name="select_all" className="text-xs" />
298
+ Pilih Semua
299
+ </button>
300
+ </div>
301
+ <div className="flex items-center gap-2">
302
+ <Button
303
+ size="lg"
304
+ disabled={selectedIds.size === 0 || bulkPublish.isPending}
305
+ onClick={() => bulkPublish.mutate({ ids: Array.from(selectedIds) })}
306
+ className="rounded-[var(--radius-md)] bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)]"
307
+ >
308
+ <MaterialIcon name="public" className="text-xs mr-1" />
309
+ {bulkPublish.isPending ? "Mempublikasikan..." : `Jadikan Publik (${selectedIds.size})`}
310
+ </Button>
311
+ <button
312
+ onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
313
+ className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
314
+ >
315
+ Selesai
316
+ </button>
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
+ />
342
+ </div>
343
+ )}
344
+
345
+ {/* Results */}
346
+ {query.isLoading ? (
347
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
348
+ {Array.from({ length: 6 }).map((_, i) => (
349
+ <Card key={i} className="h-48 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
350
+ ))}
351
+ </div>
352
+ ) : packages.length === 0 ? (
353
+ <div className="text-center py-16">
354
+ <MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
355
+ <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
356
+ <p className="text-sm text-[var(--warm-silver)] mt-1 mb-6">
357
+ {tab === "mine"
358
+ ? "Belum ada paket yang Anda buat. Buat paket dari Bank Soal."
359
+ : "Belum ada paket publik. Buat paket soal pertama Anda"}
360
+ </p>
361
+ <div className="flex items-center justify-center gap-3">
362
+ <Link to="/generate">
363
+ <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
364
+ <MaterialIcon name="auto_awesome" className="mr-2" />
365
+ Generate Soal
366
+ </Button>
367
+ </Link>
368
+ <Link to="/bank">
369
+ <Button variant="outline" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]">
370
+ <MaterialIcon name="add" className="mr-2" />
371
+ Buat Paket
372
+ </Button>
373
+ </Link>
374
+ </div>
375
+ </div>
376
+ ) : (
377
+ <>
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>
522
+
523
+ {totalPages > 1 && (
524
+ <div className="flex items-center justify-center gap-2 mt-10">
525
+ <Button
526
+ variant="outline"
527
+ onClick={() => setPage(Math.max(1, page - 1))}
528
+ disabled={page <= 1}
529
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
530
+ >
531
+ <MaterialIcon name="chevron_left" />
532
+ </Button>
533
+ <span className="text-sm text-[var(--warm-charcoal)] px-4">
534
+ Halaman {page} dari {totalPages}
535
+ </span>
536
+ <Button
537
+ variant="outline"
538
+ onClick={() => setPage(Math.min(totalPages, page + 1))}
539
+ disabled={page >= totalPages}
540
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
541
+ >
542
+ <MaterialIcon name="chevron_right" />
543
+ </Button>
544
+ </div>
545
+ )}
546
+ </>
547
+ )}
548
+
549
+ <PageTour storageKey={PACKAGES_TOUR_KEY} autoDelay={600} steps={packagesPageSteps} />
550
+ <TourHelpButton storageKey={PACKAGES_TOUR_KEY} />
551
+ </div>
552
+ );
553
+ }
554
+
555
+ // ── Packages page tour ──
556
+ const PACKAGES_TOUR_KEY = "labas-page-tour-packages";
557
+ const packagesPageSteps: Step[] = [
558
+ {
559
+ target: "[data-tour='packages-header']",
560
+ title: "Paket Soal",
561
+ content: "Temukan paket soal dari komunitas atau lihat paket buatan sendiri. Klik 'Buat Paket' untuk membuat paket baru dari Bank Soal.",
562
+ spotlightPadding: 8,
563
+ },
564
+ {
565
+ target: "[data-tour='packages-filters']",
566
+ title: "Filter & Pencarian",
567
+ content: "Cari paket berdasarkan nama atau filter berdasarkan jenis ujian (IELTS, TOEFL, dll). Bisa juga switch antara 'Semua Paket' dan 'Paket Saya'.",
568
+ spotlightPadding: 8,
569
+ },
570
+ {
571
+ target: "[data-tour='packages-list']",
572
+ title: "Mulai Latihan",
573
+ content: "Klik kartu paket untuk lihat detail, atau langsung klik 'Mulai Latihan' untuk mengerjakan soal. Pantau skor dan progres kamu!",
574
+ spotlightPadding: 8,
575
+ },
576
+ ];
577
+
578
+ function VisChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
579
+ return (
580
+ <button
581
+ onClick={onClick}
582
+ className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
583
+ active
584
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
585
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
586
+ }`}
587
+ >
588
+ {children}
589
+ </button>
590
+ );
591
+ }
apps/web/src/components/routes/SettingsPage.tsx ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, startTransition } from "react";
2
+ import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { authClient } from "@/lib/auth-client";
5
+ import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
6
+ import { trpc } from "@/utils/trpc";
7
+ import {
8
+ Tabs,
9
+ TabsList,
10
+ TabsTrigger,
11
+ TabsContent,
12
+ } from "@labas/ui/components/tabs";
13
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
14
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
15
+ import { TokenUsageChart } from "@/components/settings/TokenUsageChart";
16
+ import { ApiKeyList } from "@/components/settings/ApiKeyList";
17
+ import { ApiKeyForm } from "@/components/settings/ApiKeyForm";
18
+ import { TipsCard } from "@/components/settings/TipsCard";
19
+ import { SecurityInfo } from "@/components/settings/SecurityInfo";
20
+ import { AccountSettings } from "@/components/settings/AccountSettings";
21
+ import { z } from "zod";
22
+
23
+ export const Route = createFileRoute("/settings")({
24
+ validateSearch: z.object({
25
+ tab: z.enum(["api-keys", "token-usage", "security", "account"]).optional(),
26
+ }).parse,
27
+ beforeLoad: async () => {
28
+ const session = await authClient.getSession();
29
+ if (!session.data) {
30
+ redirect({ to: "/login", throw: true });
31
+ }
32
+ return { session };
33
+ },
34
+ });
35
+
36
+ const PROVIDERS = [
37
+ { value: "openai", label: "OpenAI" },
38
+ { value: "anthropic", label: "Anthropic" },
39
+ { value: "google", label: "Google" },
40
+ { value: "openrouter", label: "OpenRouter" },
41
+ { value: "groq", label: "Groq" },
42
+ { value: "custom", label: "Custom" },
43
+ ];
44
+
45
+ function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
46
+ return {
47
+ name: "",
48
+ provider: "openai",
49
+ baseUrl: "https://api.openai.com/v1",
50
+ modelName: "gpt-4o-mini",
51
+ maxTokens: 16384,
52
+ };
53
+ }
54
+
55
+ type Tab = "api-keys" | "token-usage" | "security" | "account";
56
+
57
+ function TokenUsageSection() {
58
+ const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
59
+
60
+ const totalTokens = data?.totalTokens ?? 0;
61
+ const jobs = data?.jobs ?? [];
62
+
63
+ return (
64
+ <div className="space-y-6">
65
+ <TokenUsageChart />
66
+
67
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
68
+ <CardHeader>
69
+ <div className="flex items-center gap-3">
70
+ <MaterialIcon name="toll" className="text-xl" />
71
+ <div>
72
+ <CardTitle className="font-headline text-[var(--clay-black)]">
73
+ Penggunaan Token Hari Ini
74
+ </CardTitle>
75
+ <CardDescription className="text-[var(--warm-charcoal)]">
76
+ Total token yang terpakai untuk generate soal hari ini.
77
+ </CardDescription>
78
+ </div>
79
+ </div>
80
+ </CardHeader>
81
+ <CardContent className="space-y-6">
82
+ <div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
83
+ <MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
84
+ <div>
85
+ <p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
86
+ <p className="text-3xl font-extrabold text-[var(--matcha-800)]">
87
+ {isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
88
+ </p>
89
+ </div>
90
+ </div>
91
+
92
+ <div>
93
+ <h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
94
+ Riwayat Generate Hari Ini
95
+ </h3>
96
+ {isLoading ? (
97
+ <div className="space-y-2">
98
+ {[1, 2, 3].map((i) => (
99
+ <div key={i} className="h-12 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
100
+ ))}
101
+ </div>
102
+ ) : jobs.length === 0 ? (
103
+ <div className="text-center py-8 border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-lg)]">
104
+ <MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
105
+ <p className="text-[var(--warm-charcoal)] font-semibold">Belum ada generate hari ini</p>
106
+ <p className="text-xs text-[var(--warm-silver)] mt-1">
107
+ Generate soal baru untuk melihat penggunaan token.
108
+ </p>
109
+ </div>
110
+ ) : (
111
+ <div className="overflow-x-auto">
112
+ <table className="w-full text-sm">
113
+ <thead>
114
+ <tr className="border-b border-[var(--oat-border)] text-[var(--warm-charcoal)]">
115
+ <th className="text-left py-2 px-3 font-medium">Waktu</th>
116
+ <th className="text-left py-2 px-3 font-medium">Mode</th>
117
+ <th className="text-left py-2 px-3 font-medium">Status</th>
118
+ <th className="text-left py-2 px-3 font-medium">Ujian</th>
119
+ <th className="text-left py-2 px-3 font-medium">Section</th>
120
+ <th className="text-right py-2 px-3 font-medium">Soal</th>
121
+ <th className="text-right py-2 px-3 font-medium">Token</th>
122
+ </tr>
123
+ </thead>
124
+ <tbody>
125
+ {jobs.map((job) => {
126
+ const isFailed = job.status === "failed";
127
+ const isCancelled = job.status === "cancelled";
128
+ return (
129
+ <tr
130
+ key={job.id}
131
+ className={`border-b border-[var(--oat-border)] last:border-0 hover:bg-[var(--warm-cream)] transition-colors ${
132
+ isFailed || isCancelled ? "opacity-70" : ""
133
+ }`}
134
+ >
135
+ <td className="py-2.5 px-3 text-[var(--clay-black)] whitespace-nowrap">
136
+ {new Date(job.createdAt).toLocaleTimeString("id-ID", {
137
+ hour: "2-digit",
138
+ minute: "2-digit",
139
+ })}
140
+ </td>
141
+ <td className="py-2.5 px-3">
142
+ <span
143
+ className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
144
+ job.mode === "agentic"
145
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
146
+ : "bg-[var(--lavender-300)] text-[var(--lavender-800)]"
147
+ }`}
148
+ >
149
+ {job.mode === "agentic" ? "Agentic" : "Quick"}
150
+ </span>
151
+ </td>
152
+ <td className="py-2.5 px-3">
153
+ {job.status === "completed" ? (
154
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--matcha-800)]">
155
+ <MaterialIcon name="check_circle" className="text-xs" />
156
+ Selesai
157
+ </span>
158
+ ) : isFailed ? (
159
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--pomegranate-400)]">
160
+ <MaterialIcon name="error" className="text-xs" />
161
+ Gagal
162
+ </span>
163
+ ) : isCancelled ? (
164
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-charcoal)]">
165
+ <MaterialIcon name="cancel" className="text-xs" />
166
+ Dibatalkan
167
+ </span>
168
+ ) : (
169
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-silver)]">
170
+ <MaterialIcon name="hourglass_empty" className="text-xs" />
171
+ {job.status}
172
+ </span>
173
+ )}
174
+ </td>
175
+ <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.examTypeId}</td>
176
+ <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.sectionTypeId}</td>
177
+ <td className="py-2.5 px-3 text-right text-[var(--clay-black)]">{job.questionCount}</td>
178
+ <td className="py-2.5 px-3 text-right text-[var(--clay-black)] font-medium">
179
+ {job.tokensUsed?.toLocaleString("id-ID") ?? "-"}
180
+ </td>
181
+ </tr>
182
+ );
183
+ })}
184
+ </tbody>
185
+ </table>
186
+ </div>
187
+ )}
188
+ </div>
189
+ </CardContent>
190
+ </Card>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ export function RouteComponent() {
196
+ const { configs, isLoading, addConfig, updateConfig, removeConfig } =
197
+ useApiKeys();
198
+
199
+ const search = Route.useSearch();
200
+ const navigate = Route.useNavigate();
201
+
202
+ const [editingId, setEditingId] = useState<string | null>(null);
203
+ const [isAdding, setIsAdding] = useState(false);
204
+ const [isSaving, setIsSaving] = useState(false);
205
+
206
+ const activeTab: Tab = search.tab ?? "api-keys";
207
+
208
+ const setTab = (tab: string) => {
209
+ startTransition(() => {
210
+ navigate({ search: { tab: tab as Tab } });
211
+ });
212
+ };
213
+
214
+ const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>(
215
+ () => ({
216
+ ...defaultConfig(),
217
+ apiKey: "",
218
+ }),
219
+ );
220
+
221
+ const resetForm = () => {
222
+ setForm({ ...defaultConfig(), apiKey: "" });
223
+ };
224
+
225
+ const handleFormChange = (field: keyof typeof form, value: string | number) => {
226
+ setForm((prev) => ({ ...prev, [field]: value }));
227
+ };
228
+
229
+ const startAdd = () => {
230
+ resetForm();
231
+ setIsAdding(true);
232
+ setEditingId(null);
233
+ };
234
+
235
+ const startEdit = (config: ApiKeyConfig) => {
236
+ setForm({
237
+ name: config.name,
238
+ provider: config.provider,
239
+ baseUrl: config.baseUrl,
240
+ modelName: config.modelName,
241
+ maxTokens: config.maxTokens ?? 16384,
242
+ apiKey: "",
243
+ });
244
+ setEditingId(config.id);
245
+ setIsAdding(false);
246
+ };
247
+
248
+ const cancelEdit = () => {
249
+ setEditingId(null);
250
+ setIsAdding(false);
251
+ resetForm();
252
+ };
253
+
254
+ const handleSave = async () => {
255
+ if (!form.name.trim()) return;
256
+ setIsSaving(true);
257
+ try {
258
+ if (isAdding) {
259
+ if (!form.apiKey) return;
260
+ await addConfig(form);
261
+ setIsAdding(false);
262
+ } else if (editingId) {
263
+ await updateConfig(editingId, form);
264
+ setEditingId(null);
265
+ }
266
+ resetForm();
267
+ } finally {
268
+ setIsSaving(false);
269
+ }
270
+ };
271
+
272
+ const isFormOpen = isAdding || editingId !== null;
273
+ const canSave =
274
+ !!form.name.trim() &&
275
+ !!form.baseUrl.trim() &&
276
+ !!form.modelName.trim() &&
277
+ (isAdding ? !!form.apiKey : true);
278
+
279
+ return (
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>
287
+ <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
288
+ Pengaturan
289
+ </h1>
290
+ <p className="text-lg text-[var(--warm-charcoal)] mt-2">
291
+ Kelola API key, pantau penggunaan token, dan preferensi akun.
292
+ </p>
293
+ </section>
294
+
295
+ <Tabs value={activeTab} onValueChange={setTab} className="w-full">
296
+ <TabsList variant="line" className="mb-6">
297
+ <TabsTrigger value="api-keys">API Keys</TabsTrigger>
298
+ <TabsTrigger value="token-usage">Token Usage</TabsTrigger>
299
+ <TabsTrigger value="account">Akun</TabsTrigger>
300
+ <TabsTrigger value="security">Keamanan</TabsTrigger>
301
+ </TabsList>
302
+
303
+ <TabsContent value="api-keys" className="space-y-8">
304
+ <div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
305
+ <div className="lg:col-span-7 space-y-8">
306
+ <ApiKeyList
307
+ configs={configs}
308
+ isLoading={isLoading}
309
+ editingId={editingId}
310
+ isFormOpen={isFormOpen}
311
+ providers={PROVIDERS}
312
+ onStartAdd={startAdd}
313
+ onStartEdit={startEdit}
314
+ onRemove={removeConfig}
315
+ />
316
+
317
+ {isFormOpen && (
318
+ <ApiKeyForm
319
+ isAdding={isAdding}
320
+ isSaving={isSaving}
321
+ form={form}
322
+ canSave={canSave}
323
+ providers={PROVIDERS}
324
+ onChange={handleFormChange}
325
+ onSave={handleSave}
326
+ onCancel={cancelEdit}
327
+ />
328
+ )}
329
+ </div>
330
+
331
+ <div className="lg:col-span-5">
332
+ <TipsCard />
333
+ </div>
334
+ </div>
335
+ </TabsContent>
336
+
337
+ <TabsContent value="token-usage">
338
+ <TokenUsageSection />
339
+ </TabsContent>
340
+
341
+ <TabsContent value="account">
342
+ <AccountSettings />
343
+ </TabsContent>
344
+
345
+ <TabsContent value="security">
346
+ <SecurityInfo />
347
+ </TabsContent>
348
+ </Tabs>
349
+ </div>
350
+ );
351
+ }
apps/web/src/routeTree.gen.ts CHANGED
@@ -52,12 +52,12 @@ const SettingsRoute = SettingsRouteImport.update({
52
  id: '/settings',
53
  path: '/settings',
54
  getParentRoute: () => rootRouteImport,
55
- } as any)
56
  const PackagesRoute = PackagesRouteImport.update({
57
  id: '/packages',
58
  path: '/packages',
59
  getParentRoute: () => rootRouteImport,
60
- } as any)
61
  const MeRoute = MeRouteImport.update({
62
  id: '/me',
63
  path: '/me',
@@ -77,7 +77,7 @@ const LandingRoute = LandingRouteImport.update({
77
  id: '/landing',
78
  path: '/landing',
79
  getParentRoute: () => rootRouteImport,
80
- } as any)
81
  const JobsRoute = JobsRouteImport.update({
82
  id: '/jobs',
83
  path: '/jobs',
@@ -92,7 +92,7 @@ const GenerateRoute = GenerateRouteImport.update({
92
  id: '/generate',
93
  path: '/generate',
94
  getParentRoute: () => rootRouteImport,
95
- } as any)
96
  const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
97
  id: '/forgot-password',
98
  path: '/forgot-password',
@@ -102,7 +102,7 @@ const BankRoute = BankRouteImport.update({
102
  id: '/bank',
103
  path: '/bank',
104
  getParentRoute: () => rootRouteImport,
105
- } as any)
106
  const AnalyticsRoute = AnalyticsRouteImport.update({
107
  id: '/analytics',
108
  path: '/analytics',
 
52
  id: '/settings',
53
  path: '/settings',
54
  getParentRoute: () => rootRouteImport,
55
+ } as any).lazy(() => import('./routes/settings.lazy').then((d) => d.Route))
56
  const PackagesRoute = PackagesRouteImport.update({
57
  id: '/packages',
58
  path: '/packages',
59
  getParentRoute: () => rootRouteImport,
60
+ } as any).lazy(() => import('./routes/packages.lazy').then((d) => d.Route))
61
  const MeRoute = MeRouteImport.update({
62
  id: '/me',
63
  path: '/me',
 
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',
 
92
  id: '/generate',
93
  path: '/generate',
94
  getParentRoute: () => rootRouteImport,
95
+ } as any).lazy(() => import('./routes/generate.lazy').then((d) => d.Route))
96
  const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
97
  id: '/forgot-password',
98
  path: '/forgot-password',
 
102
  id: '/bank',
103
  path: '/bank',
104
  getParentRoute: () => rootRouteImport,
105
+ } as any).lazy(() => import('./routes/bank.lazy').then((d) => d.Route))
106
  const AnalyticsRoute = AnalyticsRouteImport.update({
107
  id: '/analytics',
108
  path: '/analytics',
apps/web/src/routes/bank.lazy.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createLazyFileRoute } from "@tanstack/react-router";
2
+ import { BankComponent } from "@/components/routes/BankPage";
3
+
4
+ export const Route = createLazyFileRoute("/bank")({
5
+ component: BankComponent,
6
+ });
apps/web/src/routes/bank.tsx CHANGED
@@ -1,30 +1,8 @@
1
- import { useState, useEffect } from "react";
2
- import { useQuery, useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect } from "@tanstack/react-router";
4
  import { z } from "zod";
5
  import { authClient } from "@/lib/auth-client";
6
- import { trpc, queryClient } from "@/utils/trpc";
7
- import { EXAM_TYPES, SECTIONS } from "@/lib/exam-constants";
8
- import { formatLabel } from "@/lib/format";
9
- import { usePackageBuilder } from "@/hooks/use-package-builder";
10
- import { useLocalStorageBoolean } from "@/hooks/use-local-storage-boolean";
11
- import { AutoBundleModal } from "@/components/bank/AutoBundleModal";
12
- import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
13
- import { MaterialIcon } from "@/components/ui/MaterialIcon";
14
- import { PageTour, TourHelpButton } from "@/components/TourGuide";
15
- import { FilterBar } from "@/components/bank/FilterBar";
16
- import { MobileFilterSheet } from "@/components/bank/MobileFilterSheet";
17
- import { AdvancedFilters } from "@/components/bank/AdvancedFilters";
18
- import { SoalBrowser } from "@/components/bank/SoalBrowser";
19
- import { SectionBrowser } from "@/components/bank/SectionBrowser";
20
- import { BundleSidebar } from "@/components/bank/BundleSidebar";
21
- import { toast } from "sonner";
22
- import type { Step } from "react-joyride";
23
-
24
- const FILTER_ADVANCED_KEY = "labas-bank-filter-advanced";
25
 
26
  export const Route = createFileRoute("/bank")({
27
- component: BankComponent,
28
  validateSearch: z.object({
29
  mode: z.enum(["soal", "section"]).optional(),
30
  tab: z.enum(["mine", "public"]).optional(),
@@ -43,537 +21,3 @@ export const Route = createFileRoute("/bank")({
43
  return { session };
44
  },
45
  });
46
-
47
- type QuestionTab = "mine" | "public";
48
- type Mode = "soal" | "section";
49
-
50
- function BankComponent() {
51
- const search = Route.useSearch();
52
- const navigate = Route.useNavigate();
53
- const { data: session } = authClient.useSession();
54
- const userId = session?.user.id;
55
-
56
- const mode: Mode = search.mode ?? "soal";
57
- const tab: QuestionTab = search.tab ?? "public";
58
- const searchText = search.search ?? "";
59
- const examType = search.examType ?? "";
60
- const section = search.section ?? "";
61
- const format = search.format ?? "";
62
- const difficulty = search.difficulty;
63
- const visibilityFilter = search.visibility ?? "all";
64
-
65
- // ── Infinite scroll state ──
66
- const [allQuestions, setAllQuestions] = useState<any[]>([]);
67
- const [offset, setOffset] = useState(0);
68
- const limit = 12;
69
- const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
70
-
71
- // ── Sidebar / Bundle State ──
72
- const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
73
- const [bundleSections, setBundleSections] = useState<any[]>([]);
74
- const [bundleTitle, setBundleTitle] = useState("");
75
- const [bundleDescription, setBundleDescription] = useState("");
76
- const [bundleIsPublic, setBundleIsPublic] = useState(false);
77
-
78
- // ── Modals ──
79
- const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
80
- const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
81
-
82
- // ── Filter UI State ──
83
- const [isAdvancedOpen, setIsAdvancedOpen] = useLocalStorageBoolean(FILTER_ADVANCED_KEY, false);
84
- const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false);
85
-
86
- // ── Data Queries ──
87
- const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
88
- ? { isPublic: visibilityFilter === "public" }
89
- : {};
90
-
91
- const questionQuery = useQuery(
92
- trpc.question.list.queryOptions(
93
- {
94
- search: searchText || undefined,
95
- examTypeId: examType || undefined,
96
- sectionTypeId: section || undefined,
97
- format: format || undefined,
98
- difficulty,
99
- ...(tab === "mine" && userId
100
- ? { creatorUserId: userId, ...visibilityFilterParam }
101
- : { isPublic: true }),
102
- limit,
103
- offset,
104
- },
105
- { enabled: mode === "soal" },
106
- ),
107
- );
108
-
109
- // Reset offset when filters change
110
- useEffect(() => {
111
- setOffset(0);
112
- }, [filterKey]);
113
-
114
- // Append / replace questions when query data arrives
115
- useEffect(() => {
116
- const data = questionQuery.data;
117
- if (!data) return;
118
- if (offset === 0) {
119
- setAllQuestions(data.questions ?? []);
120
- } else {
121
- setAllQuestions((prev) => {
122
- const existingIds = new Set(prev.map((q: any) => q.id));
123
- const newQs = (data.questions ?? []).filter((q: any) => !existingIds.has(q.id));
124
- return [...prev, ...newQs];
125
- });
126
- }
127
- }, [questionQuery.data]);
128
-
129
- const totalQuestions = questionQuery.data?.total ?? 0;
130
- const hasMore = offset + limit < totalQuestions;
131
-
132
- const sectionQuery = useQuery(
133
- trpc.combo.availableSections.queryOptions(
134
- {
135
- examTypeId: examType || undefined,
136
- search: searchText || undefined,
137
- limit: 50,
138
- offset: 0,
139
- },
140
- { enabled: mode === "section" },
141
- ),
142
- );
143
-
144
- // ── Mutations ──
145
- const { isPending: isPackagePending, handleAutoBundle } = usePackageBuilder();
146
-
147
- const createPackage = useMutation(trpc.package.create.mutationOptions());
148
- const addSection = useMutation(trpc.package.addSection.mutationOptions());
149
- const addQuestion = useMutation(trpc.package.addQuestion.mutationOptions());
150
- const createCombo = useMutation(trpc.combo.create.mutationOptions());
151
-
152
- const togglePublic = useMutation({
153
- ...trpc.question.togglePublic.mutationOptions(),
154
- onSuccess: () => questionQuery.refetch(),
155
- });
156
-
157
- const deleteQuestion = useMutation({
158
- ...trpc.question.delete.mutationOptions(),
159
- onSuccess: () => questionQuery.refetch(),
160
- });
161
-
162
- const bulkPublish = useMutation({
163
- ...trpc.question.bulkPublish.mutationOptions(),
164
- onSuccess: (data) => {
165
- questionQuery.refetch();
166
- if (data.skipped > 0) {
167
- toast.success(
168
- `${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
169
- { description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
170
- );
171
- } else {
172
- toast.success(`${data.updated} soal berhasil dipublikasikan`);
173
- }
174
- },
175
- onError: (err: any) => {
176
- toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: err.message });
177
- },
178
- });
179
-
180
- // ── Navigation helpers ──
181
- const setMode = (newMode: Mode) => {
182
- navigate({
183
- search: {
184
- mode: newMode,
185
- tab: newMode === "soal" ? "mine" : undefined,
186
- search: "",
187
- examType: "",
188
- section: "",
189
- format: "",
190
- difficulty: undefined,
191
- },
192
- });
193
- if (newMode === "soal") setBundleSections([]);
194
- else setBundleQuestions([]);
195
- };
196
-
197
- const setSearch = (value: string) =>
198
- navigate({ search: (prev) => ({ ...prev, search: value }) });
199
-
200
- const setExamType = (value: string) =>
201
- navigate({ search: (prev) => ({ ...prev, examType: value }) });
202
-
203
- const setSection = (value: string) =>
204
- navigate({ search: (prev) => ({ ...prev, section: value }) });
205
-
206
- const setFormat = (value: string) =>
207
- navigate({ search: (prev) => ({ ...prev, format: value }) });
208
-
209
- const setDifficulty = (value: number | undefined) =>
210
- navigate({ search: (prev) => ({ ...prev, difficulty: value }) });
211
-
212
- const setVisibility = (value: "all" | "private" | "public") =>
213
- navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value }) });
214
-
215
- const setTab = (newTab: QuestionTab) =>
216
- navigate({
217
- search: {
218
- mode: "soal",
219
- tab: newTab,
220
- search: "",
221
- examType: "",
222
- section: "",
223
- format: "",
224
- difficulty: undefined,
225
- visibility: undefined,
226
- },
227
- });
228
-
229
- const clearFilters = () =>
230
- navigate({
231
- search: (prev) => ({
232
- ...prev,
233
- search: "",
234
- examType: "",
235
- section: "",
236
- format: "",
237
- difficulty: undefined,
238
- visibility: undefined,
239
- }),
240
- });
241
-
242
- const hasFilters =
243
- !!searchText || !!examType || !!section || !!format || difficulty !== undefined;
244
-
245
- // ── Active filter chips data ──
246
- const activeChips = [
247
- ...(examType ? [{ key: "examType", label: EXAM_TYPES.find((t) => t.id === examType)?.name ?? examType, onRemove: () => setExamType("") }] : []),
248
- ...(section ? [{ key: "section", label: SECTIONS.find((s) => s.id === section)?.name ?? section, onRemove: () => setSection("") }] : []),
249
- ...(format ? [{ key: "format", label: formatLabel(format), onRemove: () => setFormat("") }] : []),
250
- ...(difficulty !== undefined ? [{ key: "difficulty", label: `Lv.${difficulty}`, onRemove: () => setDifficulty(undefined) }] : []),
251
- ];
252
-
253
- // ── Bundle helpers ──
254
- const lockedExamType = bundleQuestions.length > 0 ? bundleQuestions[0]?.examTypeId : null;
255
-
256
- const isQuestionInBundle = (qid: string) =>
257
- bundleQuestions.some((q) => q.id === qid);
258
-
259
- const isSectionInBundle = (sid: string) =>
260
- bundleSections.some((s) => s.id === sid);
261
-
262
- const toggleQuestion = (q: any) => {
263
- if (lockedExamType && q.examTypeId !== lockedExamType) {
264
- toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
265
- return;
266
- }
267
- setBundleQuestions((prev) => {
268
- const exists = prev.find((x) => x.id === q.id);
269
- if (exists) return prev.filter((x) => x.id !== q.id);
270
- return [...prev, q];
271
- });
272
- };
273
-
274
- const toggleSection = (s: any) => {
275
- setBundleSections((prev) => {
276
- const exists = prev.find((x) => x.id === s.id);
277
- if (exists) return prev.filter((x) => x.id !== s.id);
278
- return [...prev, s];
279
- });
280
- };
281
-
282
- const removeFromBundle = (id: string, type: "question" | "section") => {
283
- if (type === "question") {
284
- setBundleQuestions((prev) => prev.filter((x) => x.id !== id));
285
- } else {
286
- setBundleSections((prev) => prev.filter((x) => x.id !== id));
287
- }
288
- };
289
-
290
- // ── Create handlers ──
291
- const handleCreateFromQuestions = async () => {
292
- if (!bundleTitle || bundleQuestions.length === 0) return;
293
- const first = bundleQuestions[0];
294
- try {
295
- const pkg = await createPackage.mutateAsync({
296
- title: bundleTitle,
297
- description: bundleDescription,
298
- examTypeId: first?.examTypeId ?? "",
299
- isPublic: bundleIsPublic,
300
- estimatedDurationMin: bundleQuestions.length * 2,
301
- });
302
- const sec = await addSection.mutateAsync({
303
- packageId: pkg.id,
304
- sectionTypeId: first?.sectionTypeId ?? "READING",
305
- title: `${first?.sectionTypeName ?? "Reading"} Section`,
306
- orderIndex: 0,
307
- });
308
- for (let i = 0; i < bundleQuestions.length; i++) {
309
- await addQuestion.mutateAsync({
310
- sectionId: sec.id,
311
- questionId: bundleQuestions[i].id,
312
- orderIndex: i,
313
- });
314
- }
315
- setBundleQuestions([]);
316
- setBundleTitle("");
317
- setBundleDescription("");
318
- } catch (err: any) {
319
- toast.error("Gagal membuat paket", { description: err.message });
320
- }
321
- };
322
-
323
- const handleCreateFromSections = async () => {
324
- if (!bundleTitle || bundleSections.length === 0) return;
325
- try {
326
- await createCombo.mutateAsync({
327
- title: bundleTitle,
328
- description: bundleDescription,
329
- isPublic: bundleIsPublic,
330
- sections: bundleSections.map((s, i) => ({
331
- sourcePackageId: s.packageId,
332
- sourceSectionId: s.id,
333
- orderIndex: i,
334
- })),
335
- });
336
- setBundleSections([]);
337
- setBundleTitle("");
338
- setBundleDescription("");
339
- toast.success("Combo paket berhasil dibuat!");
340
- } catch (err: any) {
341
- toast.error("Gagal membuat combo", { description: err.message });
342
- }
343
- };
344
-
345
- // ── Auto Bundle ──
346
- const autoBundleExamType = examType || lockedExamType || null;
347
- const autoBundleSectionType = section || null;
348
-
349
- const onAutoBundle = async (data: {
350
- title: string;
351
- description: string;
352
- isPublic: boolean;
353
- count: number;
354
- sortOrder: "random" | "difficulty";
355
- }) => {
356
- const batchSize = 50;
357
- const baseInput = {
358
- search: searchText || undefined,
359
- examTypeId: examType || undefined,
360
- sectionTypeId: section || undefined,
361
- format: format || undefined,
362
- difficulty,
363
- ...(tab === "mine" && userId
364
- ? { creatorUserId: userId }
365
- : { isPublic: true }),
366
- limit: batchSize,
367
- };
368
- const firstPage = await queryClient.fetchQuery(
369
- trpc.question.list.queryOptions({ ...baseInput, offset: 0 }),
370
- );
371
- const allQuestions = [...(firstPage.questions ?? [])];
372
- const totalAvailable = firstPage.total ?? allQuestions.length;
373
- for (let offset = batchSize; offset < totalAvailable; offset += batchSize) {
374
- const pageData = await queryClient.fetchQuery(
375
- trpc.question.list.queryOptions({ ...baseInput, offset }),
376
- );
377
- allQuestions.push(...(pageData.questions ?? []));
378
- }
379
- await handleAutoBundle({
380
- ...data,
381
- examTypeId: autoBundleExamType ?? "",
382
- sectionTypeId: autoBundleSectionType ?? "READING",
383
- allQuestions,
384
- });
385
- setIsAutoBundleOpen(false);
386
- };
387
-
388
- // ── Render helpers ──
389
- const questions = allQuestions;
390
-
391
- const sections = sectionQuery.data?.sections ?? [];
392
- const groupedSections = sections.reduce((groups: Record<string, any[]>, s: any) => {
393
- const key = `${s.examTypeName ?? "Unknown"} — ${s.packageTitle ?? "Untitled"}`;
394
- if (!groups[key]) groups[key] = [];
395
- groups[key].push(s);
396
- return groups;
397
- }, {} as Record<string, any[]>);
398
-
399
- const isCreating =
400
- createPackage.isPending ||
401
- addSection.isPending ||
402
- addQuestion.isPending ||
403
- createCombo.isPending;
404
-
405
- return (
406
- <div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
407
- <FilterBar
408
- mode={mode}
409
- tab={tab}
410
- searchText={searchText}
411
- examType={examType}
412
- visibility={visibilityFilter}
413
- activeChips={activeChips}
414
- hasFilters={hasFilters}
415
- isAdvancedOpen={isAdvancedOpen}
416
- lockedExamType={lockedExamType}
417
- dataTour="bank-filters"
418
- onToggleAdvanced={() => setIsAdvancedOpen((v) => !v)}
419
- onSetMode={setMode}
420
- onSetTab={setTab}
421
- onSetSearch={setSearch}
422
- onSetExamType={setExamType}
423
- onSetVisibility={setVisibility}
424
- onClearFilters={clearFilters}
425
- onOpenMobileSheet={() => setIsMobileSheetOpen(true)}
426
- advancedFilters={
427
- <AdvancedFilters
428
- section={section}
429
- format={format}
430
- difficulty={difficulty}
431
- onSetSection={setSection}
432
- onSetFormat={setFormat}
433
- onSetDifficulty={setDifficulty}
434
- />
435
- }
436
- />
437
-
438
- <div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-6">
439
- <section className="mb-8">
440
- <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
441
- Buat Paket
442
- </h1>
443
- <p className="text-lg text-[var(--warm-charcoal)] mt-2">
444
- Pilih soal atau section untuk dibuatkan paket latihan.
445
- </p>
446
- <div className="mt-3 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
447
- <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
448
- <MaterialIcon name="auto_awesome" className="text-sm" />
449
- Auto Bundle — biarkan AI pilihkan soal otomatis
450
- </span>
451
- <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
452
- <MaterialIcon name="touch_app" className="text-sm" />
453
- Manual — pilih sendiri soal satu per satu
454
- </span>
455
- </div>
456
- </section>
457
-
458
- <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
459
- <div data-tour="bank-questions" className="lg:col-span-8">
460
- {mode === "soal" ? (
461
- <SoalBrowser
462
- isLoading={questionQuery.isLoading}
463
- questions={questions}
464
- hasMore={hasMore}
465
- isFetchingNextPage={questionQuery.isFetching}
466
- onLoadMore={() => setOffset((prev) => prev + limit)}
467
- hasFilters={hasFilters}
468
- userId={userId}
469
- lockedExamType={lockedExamType}
470
- tab={tab}
471
- filterKey={filterKey}
472
- isQuestionInBundle={isQuestionInBundle}
473
- onToggleQuestion={toggleQuestion}
474
- onOpenDetail={setSelectedQuestion}
475
- onTogglePublic={(id) => togglePublic.mutate({ id })}
476
- onDelete={(id) => {
477
- if (confirm("Yakin mau hapus soal ini?")) deleteQuestion.mutate({ id });
478
- }}
479
- onClearFilters={clearFilters}
480
- onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
481
- onPublishAllPrivate={(ids) => bulkPublish.mutate({ ids })}
482
- />
483
- ) : (
484
- <SectionBrowser
485
- isLoading={sectionQuery.isLoading}
486
- groupedSections={groupedSections}
487
- isSectionInBundle={isSectionInBundle}
488
- onToggleSection={toggleSection}
489
- />
490
- )}
491
- </div>
492
-
493
- <BundleSidebar
494
- mode={mode}
495
- bundleQuestions={bundleQuestions}
496
- bundleSections={bundleSections}
497
- bundleTitle={bundleTitle}
498
- bundleDescription={bundleDescription}
499
- bundleIsPublic={bundleIsPublic}
500
- isCreating={isCreating}
501
- autoBundleExamType={autoBundleExamType}
502
- lockedExamType={lockedExamType}
503
- onSetTitle={setBundleTitle}
504
- onSetDescription={setBundleDescription}
505
- onSetIsPublic={setBundleIsPublic}
506
- onRemoveFromBundle={removeFromBundle}
507
- onCreateFromQuestions={handleCreateFromQuestions}
508
- onCreateFromSections={handleCreateFromSections}
509
- onOpenAutoBundle={() => setIsAutoBundleOpen(true)}
510
- />
511
- </div>
512
- </div>
513
-
514
- <MobileFilterSheet
515
- open={isMobileSheetOpen}
516
- onOpenChange={setIsMobileSheetOpen}
517
- section={section}
518
- format={format}
519
- difficulty={difficulty}
520
- activeChips={activeChips}
521
- onSetSection={setSection}
522
- onSetFormat={setFormat}
523
- onSetDifficulty={setDifficulty}
524
- onClearFilters={clearFilters}
525
- />
526
-
527
- {selectedQuestion && (
528
- <QuestionDetailModal
529
- question={selectedQuestion}
530
- onClose={() => setSelectedQuestion(null)}
531
- isSelected={isQuestionInBundle(selectedQuestion.id)}
532
- onToggleSelect={() => toggleQuestion(selectedQuestion)}
533
- isSelectable={true}
534
- />
535
- )}
536
-
537
- {isAutoBundleOpen && autoBundleExamType && (
538
- <AutoBundleModal
539
- availableCount={totalQuestions}
540
- examTypeName={EXAM_TYPES.find((t) => t.id === autoBundleExamType)?.name ?? autoBundleExamType}
541
- sectionTypeName={SECTIONS.find((s) => s.id === autoBundleSectionType)?.name ?? "Reading"}
542
- onClose={() => setIsAutoBundleOpen(false)}
543
- onCreate={onAutoBundle}
544
- isPending={isPackagePending}
545
- />
546
- )}
547
-
548
- <PageTour
549
- storageKey={BANK_TOUR_KEY}
550
- autoDelay={600}
551
- steps={bankPageSteps}
552
- />
553
- <TourHelpButton storageKey={BANK_TOUR_KEY} />
554
- </div>
555
- );
556
- }
557
-
558
- // ── Bank page tour ──
559
- const BANK_TOUR_KEY = "labas-page-tour-bank";
560
- const bankPageSteps: Step[] = [
561
- {
562
- target: "[data-tour='bank-filters']",
563
- title: "Filter & Mode",
564
- content: "Pilih mode 'Dari Soal' untuk pilih soal satu per satu, atau 'Dari Section' untuk gabung section dari paket yang sudah ada. Filter juga berdasarkan exam type dan kata kunci.",
565
- spotlightPadding: 8,
566
- },
567
- {
568
- target: "[data-tour='bank-questions']",
569
- title: "Daftar Soal",
570
- content: "Semua soal yang sesuai filter ditampilkan di sini. Klik soal untuk melihat detail, atau centang untuk menambahkannya ke paket.",
571
- spotlightPadding: 8,
572
- },
573
- {
574
- target: "[data-tour='bank-sidebar']",
575
- title: "Sidebar Paket",
576
- content: "Soal yang dipilih muncul di sini. Atur judul, deskripsi, dan visibilitas paket. Klik 'Auto Bundle' untuk isi otomatis, atau 'Buat Paket' untuk simpan.",
577
- spotlightPadding: 8,
578
- },
579
- ];
 
 
 
1
  import { createFileRoute, redirect } from "@tanstack/react-router";
2
  import { z } from "zod";
3
  import { authClient } from "@/lib/auth-client";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export const Route = createFileRoute("/bank")({
 
6
  validateSearch: z.object({
7
  mode: z.enum(["soal", "section"]).optional(),
8
  tab: z.enum(["mine", "public"]).optional(),
 
21
  return { session };
22
  },
23
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/generate.lazy.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createLazyFileRoute } from "@tanstack/react-router";
2
+ import { RouteComponent } from "@/components/routes/GeneratePage";
3
+
4
+ export const Route = createLazyFileRoute("/generate")({
5
+ component: RouteComponent,
6
+ });
apps/web/src/routes/generate.tsx CHANGED
@@ -1,38 +1,7 @@
1
- import { useState, useEffect, useRef } from "react";
2
- import { useMutation, useQuery } from "@tanstack/react-query";
3
- import { createFileRoute, redirect, Link } from "@tanstack/react-router";
4
  import { authClient } from "@/lib/auth-client";
5
- import { trpc } from "@/utils/trpc";
6
- import { useApiKeys } from "@/hooks/use-api-key";
7
- import { useGenerationJobs, type CompletedResult } from "@/hooks/use-generation-jobs";
8
- import { Button } from "@labas/ui/components/button";
9
- import {
10
- Select,
11
- SelectContent,
12
- SelectItem,
13
- SelectTrigger,
14
- SelectValue,
15
- } from "@labas/ui/components/select";
16
- import { MaterialIcon } from "@/components/ui/MaterialIcon";
17
- import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
18
- import { ResultSection } from "@/components/generate/ResultSection";
19
- import { PageTour, TourHelpButton } from "@/components/TourGuide";
20
- import {
21
- EXAM_TYPES,
22
- SECTIONS,
23
- FORMATS,
24
- TOPICS,
25
- DIFFICULTIES,
26
- QUESTION_COUNT_PRESETS,
27
- } from "@/lib/generate-constants";
28
- import { getDifficultyLabel } from "@/lib/difficulty-mapping";
29
- import "flag-icons/css/flag-icons.min.css";
30
- import type { Step } from "react-joyride";
31
-
32
- const MAX_PARALLEL = 3;
33
 
34
  export const Route = createFileRoute("/generate")({
35
- component: RouteComponent,
36
  beforeLoad: async () => {
37
  const session = await authClient.getSession();
38
  if (!session.data) {
@@ -41,740 +10,3 @@ export const Route = createFileRoute("/generate")({
41
  return { session };
42
  },
43
  });
44
-
45
- function RouteComponent() {
46
- const { configs, hasConfigs } = useApiKeys();
47
-
48
- const [selectedKeyId, setSelectedKeyId] = useState<string>(
49
- configs[0]?.id ?? "",
50
- );
51
-
52
- useEffect(() => {
53
- if (configs.length > 0 && !configs.find((c) => c.id === selectedKeyId)) {
54
- setSelectedKeyId(configs[0].id);
55
- }
56
- }, [configs, selectedKeyId]);
57
-
58
- const selectedConfig = configs.find((c) => c.id === selectedKeyId);
59
- const [useFreeCredits, setUseFreeCredits] = useState(false);
60
-
61
- const myCredit = useQuery(
62
- trpc.admin.getMyCredit.queryOptions(),
63
- );
64
- const hasFreeCredits = myCredit.data?.freeCreditsEnabled === true;
65
- const tokenBalance = myCredit.data?.tokenBalance ?? 0;
66
-
67
- const {
68
- activeCount,
69
- completedResults,
70
- isGenerating,
71
- error,
72
- addJob,
73
- removeJob,
74
- resetAll,
75
- setError,
76
- } = useGenerationJobs();
77
-
78
- const [activeTabIdx, setActiveTabIdx] = useState(0);
79
- const prevResultsLengthRef = useRef(0);
80
-
81
- // Auto-scroll to results when they appear
82
- const resultsRef = useRef<HTMLDivElement>(null);
83
- useEffect(() => {
84
- if (completedResults.length > 0 && resultsRef.current) {
85
- resultsRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
86
- }
87
- }, [completedResults.length]);
88
-
89
- useEffect(() => {
90
- const prev = prevResultsLengthRef.current;
91
- prevResultsLengthRef.current = completedResults.length;
92
-
93
- if (completedResults.length === 0) {
94
- setActiveTabIdx(0);
95
- return;
96
- }
97
-
98
- if (completedResults.length > prev) {
99
- setActiveTabIdx(completedResults.length - 1);
100
- return;
101
- }
102
-
103
- if (activeTabIdx >= completedResults.length) {
104
- setActiveTabIdx(completedResults.length - 1);
105
- }
106
- }, [completedResults.length]);
107
-
108
- const [examType, setExamType] = useState("IELTS");
109
- const [selectedSections, setSelectedSections] = useState<string[]>(["READING"]);
110
- const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
111
- const [difficulty, setDifficulty] = useState(2);
112
- const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
113
- const [questionCount, setQuestionCount] = useState(5);
114
- const [weaknessAlign, setWeaknessAlign] = useState(75);
115
- const [mode, setMode] = useState<"quick" | "agentic">("quick");
116
-
117
- const isReadingAndWriting = selectedSections.includes("READING") && selectedSections.includes("WRITING");
118
-
119
- useEffect(() => {
120
- setSelectedFormats((prev) => {
121
- const valid = prev.filter((f) =>
122
- FORMATS.find((fmt) => fmt.id === f)?.allowedExams.includes(examType),
123
- );
124
- if (valid.length === 0) {
125
- return ["multiple_choice"];
126
- }
127
- return valid;
128
- });
129
- }, [examType]);
130
-
131
- useEffect(() => {
132
- if (isReadingAndWriting) {
133
- if (questionCount < 20) setQuestionCount(20);
134
- if (mode === "quick") setMode("agentic");
135
- }
136
- }, [isReadingAndWriting]);
137
-
138
- const generate = useMutation({
139
- ...trpc.ai.generate.mutationOptions(),
140
- onSuccess: (data) => {
141
- addJob(data.jobId);
142
- setError(null);
143
- },
144
- onError: (err) => {
145
- setError(err.message);
146
- },
147
- });
148
-
149
- const toggleSection = (id: string) => {
150
- setSelectedSections((prev) => {
151
- if (prev.includes(id)) {
152
- if (prev.length === 1) return prev;
153
- return prev.filter((s) => s !== id);
154
- }
155
- return [...prev, id];
156
- });
157
- };
158
-
159
- const toggleFormat = (id: string) => {
160
- setSelectedFormats((prev) =>
161
- prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
162
- );
163
- };
164
-
165
- const toggleTopic = (topic: string) => {
166
- setSelectedTopics((prev) =>
167
- prev.includes(topic) ? prev.filter((t) => t !== topic) : [...prev, topic],
168
- );
169
- };
170
-
171
- const handleGenerate = () => {
172
- if (!useFreeCredits && (!hasConfigs || !selectedConfig)) {
173
- setError("API key belum dikonfigurasi. Tambahkan di Settings atau gunakan kredit gratis.");
174
- return;
175
- }
176
- if (selectedSections.length === 0) {
177
- setError("Pilih minimal 1 section.");
178
- return;
179
- }
180
- if (selectedFormats.length === 0) {
181
- setError("Pilih minimal 1 format soal.");
182
- return;
183
- }
184
-
185
- const apiKeyConfig = useFreeCredits
186
- ? undefined
187
- : {
188
- baseUrl: selectedConfig!.baseUrl,
189
- apiKey: selectedConfig!.apiKey,
190
- model: selectedConfig!.modelName,
191
- maxTokens: selectedConfig!.maxTokens ?? 16384,
192
- };
193
-
194
- generate.mutate({
195
- examType: examType as any,
196
- section: selectedSections[0] as any,
197
- selectedSections: selectedSections as any,
198
- formats: selectedFormats as any,
199
- difficulty: difficulty + 1,
200
- topics: selectedTopics,
201
- questionCount,
202
- mode,
203
- apiKeyConfig,
204
- } as any);
205
- };
206
-
207
- const sectionSplits = (() => {
208
- if (mode !== "agentic" || questionCount < 20 || selectedSections.length <= 1) return null;
209
- const base = Math.floor(questionCount / selectedSections.length);
210
- const rem = questionCount % selectedSections.length;
211
- return selectedSections.map((s, i) => ({ section: s, count: base + (i < rem ? 1 : 0) }));
212
- })();
213
-
214
- const activeResult = completedResults[activeTabIdx] ?? null;
215
-
216
- return (
217
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
218
- {/* Header */}
219
- <section className="flex flex-col gap-2 relative mb-10">
220
- <div className="absolute -left-8 -top-8 w-64 h-64 ai-glow pointer-events-none opacity-50" />
221
- <h1 className="text-4xl md:text-5xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
222
- AI Exam Generator
223
- </h1>
224
- <p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
225
- Generate soal latihan dengan AI. Pilih exam, section, format, dan topik — sisanya AI yang kerjakan.
226
- </p>
227
- <div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
228
- <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
229
- <MaterialIcon name="looks_one" className="text-sm" />
230
- Pilih exam &amp; section
231
- </span>
232
- <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
233
- <MaterialIcon name="looks_two" className="text-sm" />
234
- Atur jumlah &amp; format
235
- </span>
236
- <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] font-medium">
237
- <MaterialIcon name="looks_3" className="text-sm" />
238
- Generate &amp; simpan
239
- </span>
240
- <span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--clay-black)]/10 text-[var(--clay-black)] font-medium">
241
- <MaterialIcon name="looks_4" className="text-sm" />
242
- Buat paket dari Bank Soal
243
- </span>
244
- </div>
245
- </section>
246
-
247
- {!hasConfigs && !useFreeCredits && !hasFreeCredits && (
248
- <div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
249
- <MaterialIcon name="warning" />
250
- <span>API key belum dikonfigurasi.</span>
251
- <Link to="/settings" className="font-semibold underline">
252
- Tambahkan di Settings →
253
- </Link>
254
- </div>
255
- )}
256
-
257
- {!hasConfigs && !useFreeCredits && hasFreeCredits && (
258
- <div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] text-sm flex items-center gap-3 border-2 border-[var(--matcha-400)]">
259
- <MaterialIcon name="tips_and_updates" />
260
- <span>Belum ada API key. Kamu bisa pakai Free Credits!</span>
261
- <button onClick={() => setUseFreeCredits(true)} className="font-semibold underline">
262
- Gunakan Free Credits →
263
- </button>
264
- </div>
265
- )}
266
-
267
- <div className="mb-8 p-5 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
268
- <div className="flex items-center justify-between mb-3">
269
- <label className="text-sm font-medium text-[var(--clay-black)]">Generation Mode</label>
270
- </div>
271
- <div className="flex items-center gap-4">
272
- <button
273
- onClick={() => { setUseFreeCredits(false); }}
274
- className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
275
- !useFreeCredits
276
- ? "bg-[var(--clay-black)] text-[var(--pure-white)]"
277
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
278
- }`}
279
- >
280
- <MaterialIcon name="vpn_key" className="text-sm" />
281
- BYOK
282
- </button>
283
- {hasFreeCredits && (
284
- <button
285
- onClick={() => { setUseFreeCredits(true); }}
286
- className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
287
- useFreeCredits
288
- ? "bg-[var(--clay-black)] text-[var(--pure-white)]"
289
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
290
- }`}
291
- >
292
- <MaterialIcon name="stars" className="text-sm" />
293
- Free Credits
294
- </button>
295
- )}
296
- {useFreeCredits && (
297
- <Link to="/settings" className="text-xs text-[var(--matcha-600)] underline ml-2">
298
- Atur BYOK di Settings
299
- </Link>
300
- )}
301
- </div>
302
-
303
- {useFreeCredits && myCredit.data && (
304
- <div className="mt-4 pt-4 border-t border-[var(--oat-border)] space-y-2">
305
- <div className="flex items-center justify-between">
306
- <span className="text-sm text-[var(--warm-charcoal)]">Token kamu</span>
307
- <span className={`text-lg font-headline font-bold ${tokenBalance > 0 ? "text-[var(--clay-black)]" : "text-[var(--clay-red)]"}`}>
308
- {tokenBalance.toLocaleString()}
309
- </span>
310
- </div>
311
- {tokenBalance > 0 && (
312
- <div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
313
- <div
314
- className="h-full bg-[var(--matcha-500)] rounded-full transition-all"
315
- style={{ width: `${Math.min(100, (tokenBalance / 50000) * 100)}%` }}
316
- />
317
- </div>
318
- )}
319
- {myCredit.data.cooldownRemaining > 0 && (
320
- <p className="flex items-center gap-1.5 text-xs text-[var(--sunbeam-800)] bg-[var(--sunbeam-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
321
- <MaterialIcon name="schedule" className="text-base leading-none shrink-0" />
322
- <span>
323
- Cooldown: {myCredit.data.cooldownRemaining} hari lagi untuk auto-refill.
324
- </span>
325
- </p>
326
- )}
327
- {tokenBalance <= 0 && myCredit.data.cooldownRemaining === 0 && (
328
- <p className="text-xs text-[var(--matcha-700)] bg-[var(--matcha-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
329
- Token habis. Auto-refill tersedia saat kamu generate.
330
- </p>
331
- )}
332
- {tokenBalance <= 0 && myCredit.data.cooldownRemaining > 0 && (
333
- <p className="text-xs text-[var(--clay-red)]/80 bg-[var(--clay-red)]/5 px-3 py-1.5 rounded-[var(--radius-md)]">
334
- Token habis & dalam cooldown. Gunakan BYOK atau tunggu {myCredit.data.cooldownRemaining} hari.
335
- </p>
336
- )}
337
- </div>
338
- )}
339
-
340
- {!useFreeCredits && hasConfigs && (
341
- <div className="mt-4 pt-4 border-t border-[var(--oat-border)]">
342
- <label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">Provider / API Key</label>
343
- <div className="flex gap-3">
344
- <Select value={selectedKeyId} onValueChange={(v) => v && setSelectedKeyId(v)}>
345
- <SelectTrigger className="flex-1 h-11">
346
- <SelectValue>
347
- {selectedConfig ? `${selectedConfig.name} · ${selectedConfig.modelName}` : "Pilih provider..."}
348
- </SelectValue>
349
- </SelectTrigger>
350
- <SelectContent>
351
- {configs.map((c) => (
352
- <SelectItem key={c.id} value={c.id}>
353
- {c.name} · {c.modelName}
354
- </SelectItem>
355
- ))}
356
- </SelectContent>
357
- </Select>
358
- <Link to="/settings">
359
- <Button variant="outline" size="xl" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover">
360
- <MaterialIcon name="settings" className="mr-1" />
361
- Kelola
362
- </Button>
363
- </Link>
364
- </div>
365
- </div>
366
- )}
367
-
368
- {!useFreeCredits && !hasConfigs && !hasFreeCredits && (
369
- <p className="mt-3 text-xs text-[var(--warm-charcoal)]">
370
- Tambahkan API key di Settings dahulu.
371
- </p>
372
- )}
373
-
374
- {!useFreeCredits && !hasConfigs && hasFreeCredits && (
375
- <p className="mt-3 text-xs text-[var(--warm-charcoal)]">
376
- Belum ada API key?{" "}
377
- <button onClick={() => setUseFreeCredits(true)} className="text-[var(--matcha-600)] underline">
378
- Gunakan kredit gratis
379
- </button>
380
- {" "}atau tambah di Settings.
381
- </p>
382
- )}
383
-
384
- {useFreeCredits && !hasFreeCredits && (
385
- <p className="mt-3 text-xs text-[var(--warm-charcoal)]">
386
- Free credits sedang dinonaktifkan oleh admin.
387
- </p>
388
- )}
389
- </div>
390
-
391
- <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
392
- {/* Configuration Panel */}
393
- <div className="lg:col-span-8 flex flex-col gap-10">
394
-
395
- {/* Exam Type */}
396
- <div data-tour="generate-exam-type" className="flex flex-col gap-4">
397
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
398
- <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
399
- {EXAM_TYPES.map((t) => (
400
- <button
401
- key={t.id}
402
- onClick={() => setExamType(t.id)}
403
- className={`flex items-center gap-3 py-4 px-4 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[56px] ${
404
- examType === t.id
405
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
406
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
407
- }`}
408
- >
409
- <span className={`fi fi-${t.code} w-6 h-4 rounded-sm shadow-sm shrink-0`} />
410
- {t.name}
411
- </button>
412
- ))}
413
- </div>
414
- </div>
415
-
416
- {/* Section Selection — Multi-select */}
417
- <div data-tour="generate-section" className="flex flex-col gap-4">
418
- <div className="flex items-center justify-between">
419
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
420
- <span className="text-xs text-[var(--warm-charcoal)]">
421
- {selectedSections.length} dipilih
422
- </span>
423
- </div>
424
- <div className="flex flex-wrap gap-3">
425
- {SECTIONS.map((s) => {
426
- const isSelected = selectedSections.includes(s.id);
427
- return (
428
- <button
429
- key={s.id}
430
- onClick={() => toggleSection(s.id)}
431
- className={`flex items-center gap-2.5 px-5 py-3 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[52px] ${
432
- isSelected
433
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
434
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
435
- }`}
436
- >
437
- <MaterialIcon
438
- name={isSelected ? "check_circle" : s.icon}
439
- className={`text-base shrink-0 ${isSelected ? "text-[var(--matcha-400)]" : ""}`}
440
- />
441
- {s.name}
442
- </button>
443
- );
444
- })}
445
- </div>
446
- {selectedSections.length > 1 && (
447
- <p className="text-xs text-[var(--matcha-800)] bg-[var(--matcha-300)]/30 px-3 py-2 rounded-[var(--radius-md)]">
448
- <MaterialIcon name="tips_and_updates" className="text-xs mr-1 inline" />
449
- Kamu memilih {selectedSections.length} section. Mode Agentic dengan ≥20 soal akan otomatis membagi soal ke section yang dipilih.
450
- </p>
451
- )}
452
- </div>
453
-
454
- {/* Question Count */}
455
- <div data-tour="generate-count" className="flex flex-col gap-4">
456
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">
457
- Jumlah Soal
458
- <span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
459
- </label>
460
- <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
461
- {QUESTION_COUNT_PRESETS.map((p) => {
462
- const isDisabled = isReadingAndWriting && (p.value === 5 || p.value === 10);
463
- return (
464
- <button
465
- key={p.value}
466
- onClick={() => setQuestionCount(p.value)}
467
- disabled={isDisabled}
468
- className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover flex flex-col items-center gap-1 min-h-[72px] ${
469
- isDisabled
470
- ? "bg-[var(--oat-light)] text-[var(--warm-silver)] cursor-not-allowed opacity-50 border-2 border-[var(--oat-border)]"
471
- : questionCount === p.value
472
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
473
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
474
- }`}
475
- >
476
- <span>{p.label}</span>
477
- <span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
478
- </button>
479
- );
480
- })}
481
- </div>
482
- <div className="flex items-center gap-3 mt-1">
483
- <span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
484
- <input
485
- type="range"
486
- min={isReadingAndWriting ? 20 : 1}
487
- max={40}
488
- value={questionCount}
489
- onChange={(e) => setQuestionCount(Number(e.target.value))}
490
- className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
491
- />
492
- <span className="text-xs font-bold text-[var(--clay-black)] w-6 text-right">{questionCount}</span>
493
- </div>
494
-
495
- {/* Auto Multi-Section Preview */}
496
- {sectionSplits && (
497
- <div className="mt-2 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 border border-[var(--matcha-400)]">
498
- <div className="flex items-center gap-2 mb-2 text-[var(--matcha-800)] font-semibold text-sm">
499
- <MaterialIcon name="auto_awesome" className="text-xs" />
500
- Auto Multi-Section
501
- </div>
502
- <p className="text-[var(--matcha-800)]/80 text-xs mb-3">
503
- Mode Agentic dengan {questionCount} soal akan dibagi ke {sectionSplits.length} section:
504
- </p>
505
- <div className="flex flex-wrap gap-2">
506
- {sectionSplits.map((s) => {
507
- const sec = SECTIONS.find((sec) => sec.id === s.section);
508
- return (
509
- <span
510
- key={s.section}
511
- className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--pure-white)] text-[var(--matcha-800)] text-xs font-medium border border-[var(--matcha-400)]"
512
- >
513
- <MaterialIcon name={sec?.icon ?? "menu_book"} className="text-[10px]" />
514
- {sec?.name ?? s.section}: {s.count} soal
515
- </span>
516
- );
517
- })}
518
- </div>
519
- </div>
520
- )}
521
- </div>
522
-
523
- {/* Difficulty */}
524
- <div data-tour="generate-difficulty" className="flex flex-col gap-4">
525
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
526
- <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
527
- {DIFFICULTIES.map((d, i) => (
528
- <button
529
- key={d}
530
- onClick={() => setDifficulty(i)}
531
- className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover min-h-[56px] flex flex-col items-center ${
532
- difficulty === i
533
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
534
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
535
- }`}
536
- >
537
- <span>{getDifficultyLabel(examType, i + 1)}</span>
538
- <span className={`text-[10px] mt-0.5 ${difficulty === i ? "text-white/60" : "text-[var(--warm-silver)]"}`}>{d}</span>
539
- </button>
540
- ))}
541
- </div>
542
- </div>
543
-
544
- {/* Format Selection */}
545
- <div data-tour="generate-format" className="flex flex-col gap-4">
546
- <div className="flex items-center justify-between">
547
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
548
- <span className="text-xs text-[var(--warm-charcoal)]">
549
- {selectedFormats.length} dipilih
550
- </span>
551
- </div>
552
- <div className="flex flex-wrap gap-2">
553
- {FORMATS.filter((f) => f.allowedExams.includes(examType)).map((f) => (
554
- <button
555
- key={f.id}
556
- onClick={() => toggleFormat(f.id)}
557
- className={`px-4 py-2.5 rounded-full text-sm font-medium flex items-center gap-2 cursor-pointer transition-all clay-hover min-h-[40px] ${
558
- selectedFormats.includes(f.id)
559
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
560
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
561
- }`}
562
- >
563
- {f.name}
564
- {selectedFormats.includes(f.id) && (
565
- <MaterialIcon name="close" className="text-sm" />
566
- )}
567
- </button>
568
- ))}
569
- </div>
570
- </div>
571
-
572
- {/* Topic Focus */}
573
- <div data-tour="generate-topic" className="flex flex-col gap-4">
574
- <div className="flex items-center justify-between">
575
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
576
- <span className="text-xs text-[var(--warm-charcoal)]">
577
- {selectedTopics.length} dipilih
578
- </span>
579
- </div>
580
- <div className="flex flex-wrap gap-2">
581
- {selectedTopics.map((topic) => (
582
- <span
583
- key={topic}
584
- onClick={() => toggleTopic(topic)}
585
- className="px-4 py-2 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] font-medium flex items-center gap-2 cursor-pointer transition-all hover:brightness-95 clay-hover min-h-[40px]"
586
- >
587
- {topic} <MaterialIcon name="close" className="text-sm" />
588
- </span>
589
- ))}
590
- {TOPICS.filter((t) => !selectedTopics.includes(t)).map((topic) => (
591
- <button
592
- key={topic}
593
- onClick={() => toggleTopic(topic)}
594
- className="px-4 py-2 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] font-medium hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)] transition-all clay-hover min-h-[40px]"
595
- >
596
- {topic}
597
- </button>
598
- ))}
599
- </div>
600
- </div>
601
-
602
- {/* Weakness Alignment */}
603
- <div data-tour="generate-weakness" className="flex flex-col gap-4">
604
- <div className="flex justify-between items-end">
605
- <label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
606
- <span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
607
- Intelligent Focus
608
- </span>
609
- </div>
610
- <div className="relative py-4">
611
- <input
612
- type="range"
613
- min="0"
614
- max="100"
615
- value={weaknessAlign}
616
- onChange={(e) => setWeaknessAlign(Number(e.target.value))}
617
- className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
618
- />
619
- <div className="flex justify-between mt-4 text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)]">
620
- <span>Soal Seimbang</span>
621
- <span>Fokus Kelemahan</span>
622
- </div>
623
- </div>
624
- </div>
625
- </div>
626
-
627
- {/* Live Preview Card */}
628
- <div data-tour="generate-blueprint" className="lg:col-span-4">
629
- <TestBlueprintCard
630
- examType={examType}
631
- selectedSections={selectedSections}
632
- selectedFormats={selectedFormats}
633
- questionCount={questionCount}
634
- weaknessAlign={weaknessAlign}
635
- mode={mode}
636
- setMode={setMode}
637
- activeCount={activeCount}
638
- maxParallel={MAX_PARALLEL}
639
- generatePending={generate.isPending}
640
- hasKey={hasConfigs || useFreeCredits}
641
- error={error}
642
- onGenerate={handleGenerate}
643
- onDismissError={() => setError(null)}
644
- disableQuick={isReadingAndWriting}
645
- />
646
- </div>
647
- </div>
648
-
649
- {/* Results with Tabs */}
650
- <div ref={resultsRef}>
651
- {completedResults.length > 0 && (
652
- <div className="mt-12">
653
- <div className="flex items-center justify-between mb-4">
654
- <h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
655
- Hasil Generate
656
- </h2>
657
- <Button
658
- variant="ghost"
659
- size="sm"
660
- onClick={resetAll}
661
- className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)]"
662
- >
663
- <MaterialIcon name="delete_sweep" className="text-sm mr-1" />
664
- Bersihkan
665
- </Button>
666
- </div>
667
-
668
- {/* Tab Bar */}
669
- <div className="flex gap-1 mb-6 border-b border-[var(--oat-border)] overflow-x-auto">
670
- {completedResults.map((res, idx) => {
671
- const questions = res.result?.questions ?? [];
672
- const isActive = idx === activeTabIdx;
673
- return (
674
- <button
675
- key={res.jobId}
676
- onClick={() => setActiveTabIdx(idx)}
677
- className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
678
- isActive
679
- ? "bg-[var(--pure-white)] text-[var(--clay-black)] border-[var(--clay-black)]"
680
- : "text-[var(--warm-charcoal)] border-transparent hover:text-[var(--clay-black)] hover:bg-[var(--oat-light)]"
681
- }`}
682
- >
683
- <MaterialIcon
684
- name={questions.length > 0 ? "check_circle" : "sync"}
685
- className={`text-sm ${isActive ? "text-[var(--matcha-400)]" : ""} ${questions.length === 0 && !isActive ? "animate-spin" : ""}`}
686
- />
687
- <span>
688
- {res.mode === "agentic" ? "Agentic" : "Quick"}
689
- </span>
690
- <span className="text-xs text-[var(--warm-charcoal)]">
691
- {questions.length} soal
692
- </span>
693
- <button
694
- onClick={(e) => {
695
- e.stopPropagation();
696
- removeJob(res.jobId);
697
- }}
698
- className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/10 text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors"
699
- >
700
- <MaterialIcon name="close" className="text-xs" />
701
- </button>
702
- </button>
703
- );
704
- })}
705
- </div>
706
-
707
- {/* Active Tab Content */}
708
- {activeResult && (
709
- <ResultSection
710
- result={activeResult.result}
711
- generatedPackageId={activeResult.generatedPackageId}
712
- onClear={resetAll}
713
- />
714
- )}
715
- </div>
716
- )}
717
- </div>
718
-
719
- <PageTour
720
- storageKey={GENERATE_TOUR_KEY}
721
- autoDelay={600}
722
- steps={generatePageSteps}
723
- />
724
- <TourHelpButton storageKey={GENERATE_TOUR_KEY} />
725
- </div>
726
- );
727
- }
728
-
729
- // ── Generate page tour ──
730
- const GENERATE_TOUR_KEY = "labas-page-tour-generate";
731
- const generatePageSteps: Step[] = [
732
- {
733
- target: "[data-tour='generate-exam-type']",
734
- title: "Jenis Ujian",
735
- content: "Pilih jenis ujian yang ingin kamu latih. Tersedia IELTS, TOEFL, JLPT, HSK, Goethe, TOPIK (Korea), TOAFL (Arab), dan DELE (Spanyol).",
736
- spotlightPadding: 8,
737
- },
738
- {
739
- target: "[data-tour='generate-section']",
740
- title: "Section",
741
- content: "Pilih section yang ingin digenerate. Bisa pilih lebih dari satu. Mode Agentic dengan ≥20 soal otomatis membagi soal ke setiap section.",
742
- spotlightPadding: 8,
743
- },
744
- {
745
- target: "[data-tour='generate-count']",
746
- title: "Jumlah Soal",
747
- content: "Atur jumlah soal yang ingin digenerate via preset atau slider. Maksimal 40 soal per generate.",
748
- spotlightPadding: 8,
749
- },
750
- {
751
- target: "[data-tour='generate-difficulty']",
752
- title: "Tingkat Kesulitan",
753
- content: "Pilih tingkat kesulitan. Label menyesuaikan dengan jenis ujian yang dipilih (misal: N5-N1 untuk JLPT, Band 4.0-8.0 untuk IELTS).",
754
- spotlightPadding: 8,
755
- },
756
- {
757
- target: "[data-tour='generate-format']",
758
- title: "Format Soal",
759
- content: "Pilih format soal (multiple choice, true/false, dll). Format tersedia tergantung exam type yang dipilih.",
760
- spotlightPadding: 8,
761
- },
762
- {
763
- target: "[data-tour='generate-topic']",
764
- title: "Topik",
765
- content: "Pilih topik yang ingin difokuskan. Bisa pilih lebih dari satu topik.",
766
- spotlightPadding: 8,
767
- },
768
- {
769
- target: "[data-tour='generate-weakness']",
770
- title: "Intelligent Focus",
771
- content: "Atur fokus pada kelemahan kamu. AI akan menarget area yang perlu ditingkatkan berdasarkan riwayat jawaban.",
772
- spotlightPadding: 8,
773
- },
774
- {
775
- target: "[data-tour='generate-blueprint']",
776
- title: "Test Blueprint & Generate",
777
- content: "Ringkasan konfigurasi kamu. Pilih mode Quick (cepat) atau Agentic (multi-tahap). Klik 'Generate & Launch' untuk memulai!",
778
- spotlightPadding: 8,
779
- },
780
- ];
 
1
+ import { createFileRoute, redirect } from "@tanstack/react-router";
 
 
2
  import { authClient } from "@/lib/auth-client";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  export const Route = createFileRoute("/generate")({
 
5
  beforeLoad: async () => {
6
  const session = await authClient.getSession();
7
  if (!session.data) {
 
10
  return { session };
11
  },
12
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/landing.lazy.tsx ADDED
@@ -0,0 +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
+ });
apps/web/src/routes/landing.tsx CHANGED
@@ -1,12 +1,7 @@
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
- component: LandingPage,
10
  beforeLoad: async () => {
11
  try {
12
  const session = await authClient.getSession();
@@ -18,279 +13,3 @@ export const Route = createFileRoute("/landing")({
18
  }
19
  },
20
  });
21
-
22
- function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
23
- const [isOpen, setIsOpen] = useState(false);
24
-
25
- return (
26
- <div className={`border-b-2 py-6 ${isDark ? 'border-[var(--ube-300)]/30' : 'border-[var(--oat-border)]'} last:border-b-0`}>
27
- <button
28
- type="button"
29
- className="flex w-full items-center justify-between text-left focus:outline-none group"
30
- onClick={() => setIsOpen(!isOpen)}
31
- aria-expanded={isOpen}
32
- >
33
- <span className={`font-headline font-semibold text-2xl tracking-[-0.64px] transition-colors ${isDark ? 'text-[var(--pure-white)] group-hover:text-[var(--ube-300)]' : 'text-[var(--clay-black)] group-hover:text-[var(--matcha-700)]'}`}>
34
- {question}
35
- </span>
36
- <div className={`w-10 h-10 rounded-full border-2 flex items-center justify-center transition-transform duration-300 ${isOpen ? "rotate-180" : ""} ${isDark ? 'border-[var(--ube-300)]/50 text-[var(--pure-white)] group-hover:bg-[var(--ube-300)]/20' : 'border-[var(--oat-border)] text-[var(--clay-black)] group-hover:bg-[var(--oat-light)]'}`}>
37
- <MaterialIcon
38
- name="expand_more"
39
- className="text-2xl"
40
- />
41
- </div>
42
- </button>
43
- <div
44
- className={`overflow-hidden transition-all duration-300 ease-in-out ${isOpen ? "max-h-96 mt-4 opacity-100" : "max-h-0 opacity-0"}`}
45
- >
46
- <p className={`text-lg leading-relaxed pr-12 ${isDark ? 'text-[var(--ube-300)]' : 'text-[var(--warm-charcoal)]'}`}>
47
- {answer}
48
- </p>
49
- </div>
50
- </div>
51
- );
52
- }
53
-
54
- function LandingPage() {
55
- return (
56
- <div className="min-h-screen bg-[var(--warm-cream)] flex flex-col font-sans selection:bg-[var(--matcha-300)] selection:text-[var(--clay-black)]">
57
- {/* Navbar */}
58
- <nav className="w-full px-6 py-4 md:px-12 lg:px-16 flex items-center justify-between max-w-7xl mx-auto z-50 sticky top-0 bg-[var(--warm-cream)] border-b-2 border-[var(--oat-border)]">
59
- <div className="flex items-center gap-3">
60
- <img src="/logo.png" alt="Labas Logo" className="h-10 w-auto object-contain" />
61
- <span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
62
- </div>
63
- <div className="flex items-center gap-4">
64
- <Link to="/login">
65
- <Button variant="ghost" className="text-[var(--clay-black)] font-semibold hover:bg-[var(--oat-light)] rounded-[12px] text-lg px-6 h-12">
66
- Masuk
67
- </Button>
68
- </Link>
69
- <Link to="/login">
70
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--dark-charcoal)] rounded-[24px] h-12 px-8 font-semibold text-lg clay-hover clay-shadow">
71
- Mulai Gratis
72
- </Button>
73
- </Link>
74
- </div>
75
- </nav>
76
-
77
- <main className="flex-1 flex flex-col items-center overflow-x-hidden">
78
- {/* HUGE HERO SECTION */}
79
- <section className="w-full px-6 md:px-12 lg:px-16 pt-16 pb-20 md:pt-24 md:pb-32 flex flex-col xl:flex-row items-center justify-between gap-12 lg:gap-16 relative max-w-[1440px] mx-auto">
80
- <div className="flex-1 text-center xl:text-left space-y-8 z-10 w-full max-w-[800px] xl:max-w-none">
81
- <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--matcha-300)] border-2 border-[var(--matcha-800)] text-[var(--clay-black)] uppercase-label shadow-sm mx-auto xl:mx-0 transform -rotate-2">
82
- <MaterialIcon name="auto_awesome" className="text-sm" />
83
- <span>Didukung oleh AI Generative</span>
84
- </div>
85
-
86
- <h1 className="text-[50px] md:text-[70px] lg:text-[85px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] lg:tracking-[-3.2px] leading-[1.0] lg:leading-[0.95] drop-shadow-sm">
87
- Platform Latihan Bahasa Cerdas.
88
- </h1>
89
-
90
- <p className="text-xl md:text-2xl text-[var(--warm-charcoal)] max-w-2xl mx-auto xl:mx-0 leading-relaxed">
91
- Persiapkan dirimu untuk ujian bahasa asing dengan latihan soal interaktif, mock test realistis, dan AI Generator super cepat.
92
- </p>
93
-
94
- <div className="flex flex-col sm:flex-row items-center gap-6 justify-center xl:justify-start pt-6">
95
- <Link to="/login" className="w-full sm:w-auto">
96
- <Button className="w-full sm:w-auto bg-[var(--pure-white)] text-[var(--clay-black)] rounded-[24px] h-[64px] px-10 text-xl font-semibold border-2 border-[var(--oat-border)] clay-shadow clay-hover">
97
- Mulai Latihan Sekarang
98
- </Button>
99
- </Link>
100
- </div>
101
- </div>
102
-
103
- {/* MASSIVE HERO IMAGE WITH FLOATING ASSETS - SIDE BY SIDE ON DESKTOP */}
104
- <div className="flex-1 w-full relative group mt-8 xl:mt-0 flex justify-center xl:justify-end">
105
- <div className="relative w-full max-w-[650px] lg:max-w-[750px]">
106
- <img
107
- src="/hero_img.png"
108
- alt="Labas Dashboard Preview"
109
- className="w-full h-auto object-contain transform transition-transform duration-700 group-hover:scale-105 group-hover:-rotate-1 drop-shadow-[0_20px_50px_rgba(0,0,0,0.15)] relative z-10"
110
- />
111
- {/* Variatif Floating Elements */}
112
- <div className="absolute -top-8 -left-8 md:-top-12 md:-left-12 w-32 md:w-40 h-auto z-20 hidden sm:block">
113
- <img src="/generateai.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
114
- </div>
115
- <div className="absolute -bottom-8 -right-8 md:-bottom-12 md:-right-12 w-40 md:w-48 h-auto z-20 hidden sm:block">
116
- <img src="/mocktest.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
117
- </div>
118
- <div className="absolute top-[40%] -left-16 md:-left-24 w-24 md:w-32 h-auto z-0 hidden lg:block opacity-80 blur-[1px]">
119
- <img src="/progress.png" alt="floating element" className="w-full h-auto drop-shadow-xl" />
120
- </div>
121
- </div>
122
- </div>
123
- </section>
124
-
125
- {/* Feature Cards Section */}
126
- <section id="features" className="w-full bg-[var(--pure-white)] py-32 border-y-2 border-dashed border-[var(--oat-border)]">
127
- <div className="max-w-7xl mx-auto px-6 md:px-12 lg:px-16">
128
- <div className="text-center max-w-4xl mx-auto mb-24 space-y-6">
129
- <h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] leading-tight">
130
- Fitur Unggulan Labas
131
- </h2>
132
- <p className="text-2xl text-[var(--warm-charcoal)]">
133
- Desain yang elegan, namun kokoh untuk mempercepat kesiapan Anda menghadapi ujian.
134
- </p>
135
- </div>
136
-
137
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
138
- {/* Feature 1 */}
139
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
140
- <div className="h-56 w-full flex items-center justify-center p-2">
141
- <img src="/generateai.png" alt="AI Generator" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
142
- </div>
143
- <CardContent className="p-6">
144
- <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
145
- AI Generator
146
- </h3>
147
- <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
148
- Hasilkan soal latihan baru menggunakan AI. Tentukan topik, level kesulitan, dan format dalam hitungan detik.
149
- </p>
150
- </CardContent>
151
- </Card>
152
-
153
- {/* Feature 2 */}
154
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
155
- <div className="h-56 w-full flex items-center justify-center p-2">
156
- <img src="/latihansoal.png" alt="Latihan Soal" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
157
- </div>
158
- <CardContent className="p-6">
159
- <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
160
- Latihan Terfokus
161
- </h3>
162
- <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
163
- Akses ribuan soal latihan dari bank soal. Buat paket latihan Anda sendiri dan fokus pada area kelemahan.
164
- </p>
165
- </CardContent>
166
- </Card>
167
-
168
- {/* Feature 3 */}
169
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
170
- <div className="h-56 w-full flex items-center justify-center p-2">
171
- <img src="/mocktest.png" alt="Mock Test" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
172
- </div>
173
- <CardContent className="p-6">
174
- <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
175
- Simulasi Ujian
176
- </h3>
177
- <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
178
- Simulasikan suasana ujian sesungguhnya dengan batas waktu, antarmuka imersif, dan penilaian instan.
179
- </p>
180
- </CardContent>
181
- </Card>
182
-
183
- {/* Feature 4 */}
184
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
185
- <div className="h-56 w-full flex items-center justify-center p-2">
186
- <img src="/progress.png" alt="Progress Tracking" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
187
- </div>
188
- <CardContent className="p-6">
189
- <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
190
- Analitik Progres
191
- </h3>
192
- <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
193
- Lacak perkembangan nilai Anda dari waktu ke waktu. Analisis mendalam untuk setiap bagian tes bahasa.
194
- </p>
195
- </CardContent>
196
- </Card>
197
-
198
- {/* Feature 5 */}
199
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
200
- <div className="h-56 w-full flex items-center justify-center p-2">
201
- <img src="/vocabulary.png" alt="Vocabulary" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
202
- </div>
203
- <CardContent className="p-6">
204
- <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
205
- Kosakata
206
- </h3>
207
- <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
208
- Perkaya kosakata Anda dengan metode cerdas dan pengulangan berkala yang dioptimalkan.
209
- </p>
210
- </CardContent>
211
- </Card>
212
-
213
- {/* Feature 6 */}
214
- <Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
215
- <div className="h-56 w-full flex items-center justify-center p-2">
216
- <img src="/diskusi.png" alt="Diskusi" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
217
- </div>
218
- <CardContent className="p-6">
219
- <h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
220
- Forum Diskusi
221
- </h3>
222
- <p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
223
- Diskusikan soal-soal sulit dengan komunitas pembelajar lainnya dan dapatkan penjelasan ahli.
224
- </p>
225
- </CardContent>
226
- </Card>
227
- </div>
228
- </div>
229
- </section>
230
-
231
- {/* SWATCH ROOM 1: Ube FAQ Section */}
232
- <section className="w-full bg-[var(--ube-800)] py-32 rounded-t-[40px] mt-[-40px] z-10 relative shadow-[0_-10px_40px_rgba(0,0,0,0.1)] border-t-2 border-[var(--oat-border)]/20">
233
- <div className="max-w-4xl mx-auto px-6 md:px-12 lg:px-16">
234
- <div className="text-center mb-16">
235
- <h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--pure-white)] tracking-[-2.4px] mb-6 leading-tight drop-shadow-sm">
236
- Pertanyaan Umum
237
- </h2>
238
- <p className="text-[var(--ube-300)] text-2xl max-w-2xl mx-auto">
239
- Temukan jawaban cepat untuk pertanyaan seputar Labas.
240
- </p>
241
- </div>
242
-
243
- <div className="bg-[var(--ube-900)]/40 backdrop-blur-sm border-2 border-[var(--ube-300)]/30 rounded-[32px] p-8 md:p-12 shadow-2xl">
244
- <FaqItem
245
- isDark
246
- question="Apa itu Labas?"
247
- answer="Labas adalah platform latihan ujian bahasa berbasis AI yang dirancang untuk membantu Anda berlatih dan menguasai bahasa asing melalui simulasi, bank soal interaktif, dan analitik performa."
248
- />
249
- <FaqItem
250
- isDark
251
- question="Bagaimana cara kerja AI Generator?"
252
- answer="Fitur AI Generator memungkinkan Anda membuat paket soal baru berdasarkan konteks atau topik tertentu. Anda cukup memasukkan teks acuan, dan AI Agent kami akan memproduksi soal secara otomatis."
253
- />
254
- <FaqItem
255
- isDark
256
- question="Apakah Labas sepenuhnya gratis?"
257
- answer="Platform Labas dapat digunakan secara gratis untuk fitur dasar. Untuk fitur generasi soal berbasis AI, kami menggunakan model Bring-Your-Own-Key (BYOK). Anda cukup memasukkan API Key OpenAI Anda."
258
- />
259
- <FaqItem
260
- isDark
261
- question="Bahasa apa saja yang didukung oleh Labas?"
262
- answer="Saat ini Labas mendukung latihan untuk berbagai ujian profisiensi bahasa populer seperti Bahasa Inggris (TOEFL, IELTS, TOEIC), Jepang (JLPT), Korea (TOPIK), dan banyak lagi."
263
- />
264
- </div>
265
- </div>
266
- </section>
267
-
268
- {/* SWATCH ROOM 2: Matcha CTA Section */}
269
- <section className="w-full bg-[var(--matcha-800)] py-40 px-6 rounded-t-[40px] mt-[-40px] z-20 relative shadow-[0_-10px_40px_rgba(0,0,0,0.2)] border-t-2 border-[var(--matcha-600)]">
270
- <div className="max-w-4xl mx-auto text-center space-y-12">
271
- <h2 className="text-[60px] md:text-[80px] font-headline font-semibold text-[var(--pure-white)] tracking-[-3.2px] leading-[0.95] drop-shadow-md">
272
- Siap Meningkatkan Skor Anda?
273
- </h2>
274
- <p className="text-2xl text-[var(--matcha-300)] max-w-2xl mx-auto">
275
- Bergabung sekarang dan rasakan perbedaan belajar dengan teknologi yang berpusat pada perkembangan Anda.
276
- </p>
277
- <div className="pt-8">
278
- <Link to="/login" className="inline-block">
279
- <Button className="bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] rounded-[24px] h-[80px] px-14 text-2xl font-bold clay-shadow clay-hover">
280
- Daftar Sekarang - Gratis
281
- </Button>
282
- </Link>
283
- </div>
284
- </div>
285
- </section>
286
- </main>
287
-
288
- {/* Footer */}
289
- <footer className="w-full bg-[var(--pure-white)] py-12 border-t-2 border-[var(--oat-border)] text-center relative z-30">
290
- <p className="text-[var(--warm-charcoal)] font-semibold text-lg">
291
- &copy; {new Date().getFullYear()} Labas. Didesain dengan penuh kehangatan.
292
- </p>
293
- </footer>
294
- </div>
295
- );
296
- }
 
1
+ import { createFileRoute, redirect } from "@tanstack/react-router";
 
 
 
 
2
  import { authClient } from "@/lib/auth-client";
3
 
4
  export const Route = createFileRoute("/landing")({
 
5
  beforeLoad: async () => {
6
  try {
7
  const session = await authClient.getSession();
 
13
  }
14
  },
15
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/packages.lazy.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createLazyFileRoute } from "@tanstack/react-router";
2
+ import { PackagesComponent } from "@/components/routes/PackagesPage";
3
+
4
+ export const Route = createLazyFileRoute("/packages")({
5
+ component: PackagesComponent,
6
+ });
apps/web/src/routes/packages.tsx CHANGED
@@ -1,31 +1,8 @@
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
- Select,
12
- SelectContent,
13
- SelectGroup,
14
- SelectItem,
15
- SelectTrigger,
16
- SelectValue,
17
- } from "@labas/ui/components/select";
18
- import { MaterialIcon } from "@/components/ui/MaterialIcon";
19
- import { GettingStartedCard } from "@/components/GettingStartedCard";
20
- import { CalloutCard } from "@/components/bank/CalloutCard";
21
- import { PageTour, TourHelpButton } from "@/components/TourGuide";
22
- import type { Step } from "react-joyride";
23
- import { toast } from "sonner";
24
- import { getErrorMessage } from "@/lib/error-utils";
25
- import { EXAM_TYPES } from "@/lib/exam-constants";
26
 
27
  export const Route = createFileRoute("/packages")({
28
- component: PackagesComponent,
29
  validateSearch: z.object({
30
  tab: z.enum(["all", "mine"]).optional(),
31
  search: z.string().optional(),
@@ -41,552 +18,3 @@ export const Route = createFileRoute("/packages")({
41
  return { session };
42
  },
43
  });
44
-
45
- type Tab = "all" | "mine";
46
-
47
- function PackagesComponent() {
48
- const routerNavigate = useNavigate();
49
- const search = Route.useSearch();
50
- const navigate = Route.useNavigate();
51
- const { data: session } = authClient.useSession();
52
- const userId = session?.user.id;
53
-
54
- const tab = search.tab ?? "all";
55
- const searchText = search.search ?? "";
56
- const examType = search.examType ?? "";
57
- const page = search.page ?? 1;
58
- const visibilityFilter = search.visibility ?? "all";
59
- const limit = 12;
60
-
61
- const allQuery = useQuery(
62
- trpc.package.list.queryOptions(
63
- {
64
- isPublic: true,
65
- examTypeId: examType || undefined,
66
- search: searchText || undefined,
67
- limit,
68
- offset: (page - 1) * limit,
69
- },
70
- { enabled: tab === "all" },
71
- ),
72
- );
73
-
74
- const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
75
- ? { isPublic: visibilityFilter === "public" }
76
- : {};
77
-
78
- const mineQuery = useQuery(
79
- trpc.package.myPackages.queryOptions(
80
- {
81
- search: searchText || undefined,
82
- examTypeId: examType || undefined,
83
- limit,
84
- offset: (page - 1) * limit,
85
- ...visibilityFilterParam,
86
- },
87
- { enabled: tab === "mine" },
88
- ),
89
- );
90
-
91
- const query = tab === "all" ? allQuery : mineQuery;
92
- const packages = query.data?.packages ?? [];
93
- const total = query.data?.total ?? 0;
94
- const totalPages = Math.ceil(total / limit);
95
-
96
- const updateMutation = useMutation(
97
- trpc.package.update.mutationOptions({
98
- onSuccess: () => {
99
- query.refetch();
100
- },
101
- }),
102
- );
103
-
104
- const togglePublic = (pkgId: string, current: boolean) => {
105
- updateMutation.mutate({ id: pkgId, isPublic: !current });
106
- };
107
-
108
- const bulkPublish = useMutation(
109
- trpc.package.bulkPublish.mutationOptions({
110
- onSuccess: (data) => {
111
- query.refetch();
112
- setBulkMode(false);
113
- setSelectedIds(new Set());
114
- if (data.skipped > 0) {
115
- toast.success(
116
- `${data.updated} paket dipublikasikan, ${data.skipped} dilewati`,
117
- { description: "Beberapa paket bukan milikmu atau sudah tidak tersedia." },
118
- );
119
- } else {
120
- toast.success(`${data.updated} paket berhasil dipublikasikan`);
121
- }
122
- },
123
- onError: (err: unknown) => {
124
- toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang paket.", { description: getErrorMessage(err) });
125
- },
126
- }),
127
- );
128
-
129
- // ── Bulk select ──
130
- const [bulkMode, setBulkMode] = useState(false);
131
- const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
132
-
133
- const toggleSelect = (id: string) => {
134
- setSelectedIds((prev) => {
135
- const next = new Set(prev);
136
- if (next.has(id)) next.delete(id);
137
- else next.add(id);
138
- return next;
139
- });
140
- };
141
-
142
- const clearSelection = () => setSelectedIds(new Set());
143
- const selectAll = () => setSelectedIds(new Set(packages.map((p) => p.id)));
144
-
145
- useEffect(() => {
146
- setBulkMode(false);
147
- setSelectedIds(new Set());
148
- }, [tab, searchText, examType]);
149
-
150
- const setTab = (newTab: Tab) => {
151
- navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
152
- };
153
-
154
- // ── Private callout state ──
155
- const [calloutDismissed, setCalloutDismissed] = useState(
156
- typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
157
- );
158
- const privatePackages = packages.filter(
159
- (p) => !p.isPublic && p.creatorUserId === userId,
160
- );
161
-
162
- const handleDismissCallout = () => {
163
- localStorage.setItem("labas-packages-private-callout-dismissed", "true");
164
- setCalloutDismissed(true);
165
- };
166
-
167
- const handlePublishAllPrivate = () => {
168
- const ids = privatePackages.map((p) => p.id);
169
- if (ids.length > 0) bulkPublish.mutate({ ids });
170
- };
171
-
172
- const setSearch = (value: string) => {
173
- navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
174
- };
175
-
176
- const setExamType = (value: string) => {
177
- navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
178
- };
179
-
180
- const setVisibility = (value: "all" | "private" | "public") => {
181
- navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value, page: 1 }) });
182
- };
183
-
184
- const setPage = (newPage: number) => {
185
- navigate({ search: (prev) => ({ ...prev, page: newPage }) });
186
- };
187
-
188
- return (
189
- <div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
190
- <section className="mb-8">
191
- <div data-tour="packages-header" className="flex items-center justify-between">
192
- <div>
193
- <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
194
- Paket Soal
195
- </h1>
196
- <p className="text-lg text-[var(--warm-charcoal)] mt-2">
197
- Kumpulan paket latihan dari komunitas.
198
- </p>
199
- </div>
200
- <Link to="/bank">
201
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] h-11">
202
- <MaterialIcon name="add" />
203
- <span className="ml-2 hidden sm:inline">Buat Paket</span>
204
- </Button>
205
- </Link>
206
- </div>
207
- </section>
208
-
209
- {/* Getting Started Guide */}
210
- <GettingStartedCard />
211
-
212
- {/* Tabs */}
213
- <div className="flex gap-2 mb-6">
214
- <button
215
- onClick={() => setTab("all")}
216
- className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
217
- tab === "all"
218
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
219
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
220
- }`}
221
- >
222
- Semua Paket
223
- </button>
224
- <button
225
- onClick={() => setTab("mine")}
226
- className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
227
- tab === "mine"
228
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
229
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
230
- }`}
231
- >
232
- Paket Saya
233
- </button>
234
- </div>
235
-
236
- {/* Filters */}
237
- <div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
238
- <div className="relative flex-1 max-w-md">
239
- <MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
240
- <Input
241
- placeholder="Cari paket..."
242
- value={searchText}
243
- onChange={(e) => setSearch(e.target.value)}
244
- className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
245
- />
246
- </div>
247
- <Select
248
- value={examType}
249
- onValueChange={(v: string | null) => setExamType(v ?? "")}
250
- >
251
- <SelectTrigger className="w-36">
252
- <SelectValue placeholder="Semua Ujian" />
253
- </SelectTrigger>
254
- <SelectContent>
255
- <SelectGroup>
256
- <SelectItem value="">Semua Ujian</SelectItem>
257
- {EXAM_TYPES.map((t) => (
258
- <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
259
- ))}
260
- </SelectGroup>
261
- </SelectContent>
262
- </Select>
263
- {tab === "mine" && (
264
- <div className="flex gap-2">
265
- <VisChip active={visibilityFilter === "all"} onClick={() => setVisibility("all")}>
266
- <MaterialIcon name="visibility" className="text-xs" />
267
- Semua
268
- </VisChip>
269
- <VisChip active={visibilityFilter === "private"} onClick={() => setVisibility("private")}>
270
- <MaterialIcon name="lock" className="text-xs" />
271
- Privat
272
- </VisChip>
273
- <VisChip active={visibilityFilter === "public"} onClick={() => setVisibility("public")}>
274
- <MaterialIcon name="public" className="text-xs" />
275
- Publik
276
- </VisChip>
277
- </div>
278
- )}
279
- </div>
280
-
281
- {/* Bulk toolbar */}
282
- {tab === "mine" && (
283
- <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)]">
284
- {bulkMode ? (
285
- <>
286
- <div className="flex items-center gap-3">
287
- <button
288
- onClick={clearSelection}
289
- className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center gap-1"
290
- >
291
- <MaterialIcon name="close" className="text-xs" />
292
- Batalkan ({selectedIds.size})
293
- </button>
294
- <button
295
- onClick={selectAll}
296
- className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
297
- >
298
- <MaterialIcon name="select_all" className="text-xs" />
299
- Pilih Semua
300
- </button>
301
- </div>
302
- <div className="flex items-center gap-2">
303
- <Button
304
- size="lg"
305
- disabled={selectedIds.size === 0 || bulkPublish.isPending}
306
- onClick={() => bulkPublish.mutate({ ids: Array.from(selectedIds) })}
307
- className="rounded-[var(--radius-md)] bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)]"
308
- >
309
- <MaterialIcon name="public" className="text-xs mr-1" />
310
- {bulkPublish.isPending ? "Mempublikasikan..." : `Jadikan Publik (${selectedIds.size})`}
311
- </Button>
312
- <button
313
- onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
314
- className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
315
- >
316
- Selesai
317
- </button>
318
- </div>
319
- </>
320
- ) : (
321
- <>
322
- <span className="text-sm text-[var(--warm-charcoal)]">{packages.length} paket</span>
323
- <button
324
- onClick={() => setBulkMode(true)}
325
- className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
326
- >
327
- <MaterialIcon name="select_all" className="text-sm" />
328
- Pilih Banyak
329
- </button>
330
- </>
331
- )}
332
- </div>
333
- )}
334
-
335
- {/* Private package callout */}
336
- {tab === "mine" && privatePackages.length > 0 && !calloutDismissed && (
337
- <div className="mb-6">
338
- <CalloutCard
339
- privateCount={privatePackages.length}
340
- onPublishAll={handlePublishAllPrivate}
341
- onDismiss={handleDismissCallout}
342
- />
343
- </div>
344
- )}
345
-
346
- {/* Results */}
347
- {query.isLoading ? (
348
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
349
- {Array.from({ length: 6 }).map((_, i) => (
350
- <Card key={i} className="h-48 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
351
- ))}
352
- </div>
353
- ) : packages.length === 0 ? (
354
- <div className="text-center py-16">
355
- <MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
356
- <p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
357
- <p className="text-sm text-[var(--warm-silver)] mt-1 mb-6">
358
- {tab === "mine"
359
- ? "Belum ada paket yang Anda buat. Buat paket dari Bank Soal."
360
- : "Belum ada paket publik. Buat paket soal pertama Anda"}
361
- </p>
362
- <div className="flex items-center justify-center gap-3">
363
- <Link to="/generate">
364
- <Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
365
- <MaterialIcon name="auto_awesome" className="mr-2" />
366
- Generate Soal
367
- </Button>
368
- </Link>
369
- <Link to="/bank">
370
- <Button variant="outline" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]">
371
- <MaterialIcon name="add" className="mr-2" />
372
- Buat Paket
373
- </Button>
374
- </Link>
375
- </div>
376
- </div>
377
- ) : (
378
- <>
379
- <div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
380
- {packages.map((pkg) => {
381
- const isOwner = pkg.creatorUserId === userId;
382
- const isSelected = selectedIds.has(pkg.id);
383
- return (
384
- <Card
385
- key={pkg.id}
386
- className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
387
- bulkMode && isSelected
388
- ? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
389
- : isOwner && !pkg.isPublic && !bulkMode
390
- ? "border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4"
391
- : "border-[var(--oat-border)]"
392
- }`}
393
- >
394
- <CardContent className="p-5 flex flex-col h-full">
395
- <div
396
- className="block flex-1 cursor-pointer"
397
- onClick={bulkMode ? () => toggleSelect(pkg.id) : undefined}
398
- >
399
- <Link
400
- to="/package/$id"
401
- params={{ id: pkg.id }}
402
- className={bulkMode ? "pointer-events-none" : ""}
403
- >
404
- <div className="flex items-start justify-between mb-3">
405
- <div className="flex gap-2 flex-wrap">
406
- {bulkMode && (
407
- <span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
408
- isSelected
409
- ? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
410
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
411
- }`}>
412
- <MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
413
- {isSelected ? "Terpilih" : "Pilih"}
414
- </span>
415
- )}
416
- <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">
417
- {pkg.examTypeName}
418
- </span>
419
- {isOwner && !bulkMode && (
420
- <span
421
- className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
422
- pkg.isPublic
423
- ? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
424
- : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
425
- }`}
426
- >
427
- {!pkg.isPublic && <MaterialIcon name="lock" className="text-[10px]" />}
428
- {pkg.isPublic ? "Publik" : "Privat"}
429
- </span>
430
- )}
431
- </div>
432
- {pkg.avgRating && (
433
- <div className="flex items-center gap-1 text-[var(--lemon-700)]">
434
- <MaterialIcon name="star" className="text-sm" />
435
- <span className="text-xs font-bold">{pkg.avgRating}</span>
436
- </div>
437
- )}
438
- </div>
439
-
440
- <h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
441
- {pkg.title}
442
- </h3>
443
-
444
- {pkg.description && (
445
- <p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
446
- {pkg.description}
447
- </p>
448
- )}
449
- </Link>
450
-
451
- <div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
452
- <div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
453
- <span className="flex items-center gap-1">
454
- <MaterialIcon name="quiz" className="text-xs" />
455
- {pkg.totalQuestions}
456
- </span>
457
- <span className="flex items-center gap-1">
458
- <MaterialIcon name="folder" className="text-xs" />
459
- {pkg.totalSections}
460
- </span>
461
- {pkg.estimatedDurationMin && (
462
- <span className="flex items-center gap-1">
463
- <MaterialIcon name="timer" className="text-xs" />
464
- {pkg.estimatedDurationMin}m
465
- </span>
466
- )}
467
- </div>
468
- <span className="text-xs text-[var(--warm-silver)]">
469
- {pkg.usageCount}x digunakan
470
- </span>
471
- </div>
472
- </div>
473
-
474
- {/* Owner actions */}
475
- {isOwner && !bulkMode && (
476
- <div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
477
- <button
478
- onClick={() => togglePublic(pkg.id, pkg.isPublic)}
479
- disabled={updateMutation.isPending}
480
- title={pkg.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
481
- className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors flex items-center gap-1 cursor-pointer ${
482
- pkg.isPublic
483
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
484
- : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
485
- }`}
486
- >
487
- {!pkg.isPublic && <MaterialIcon name="lock" className="text-xs" />}
488
- {pkg.isPublic ? "Publik" : "Privat"}
489
- </button>
490
- {pkg.isPublic && (
491
- <button
492
- onClick={() => {
493
- const url = `${window.location.origin}/package/${pkg.id}`;
494
- navigator.clipboard.writeText(url);
495
- toast.success("Link paket disalin!");
496
- }}
497
- 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"
498
- >
499
- <MaterialIcon name="share" className="text-xs" />
500
- Bagikan
501
- </button>
502
- )}
503
- </div>
504
- )}
505
-
506
- {!bulkMode && (
507
- <div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
508
- <Button
509
- className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
510
- onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
511
- size="xl"
512
- >
513
- <MaterialIcon name="play_arrow" className="mr-2" />
514
- Mulai Latihan
515
- </Button>
516
- </div>
517
- )}
518
- </CardContent>
519
- </Card>
520
- );
521
- })}
522
- </div>
523
-
524
- {totalPages > 1 && (
525
- <div className="flex items-center justify-center gap-2 mt-10">
526
- <Button
527
- variant="outline"
528
- onClick={() => setPage(Math.max(1, page - 1))}
529
- disabled={page <= 1}
530
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
531
- >
532
- <MaterialIcon name="chevron_left" />
533
- </Button>
534
- <span className="text-sm text-[var(--warm-charcoal)] px-4">
535
- Halaman {page} dari {totalPages}
536
- </span>
537
- <Button
538
- variant="outline"
539
- onClick={() => setPage(Math.min(totalPages, page + 1))}
540
- disabled={page >= totalPages}
541
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
542
- >
543
- <MaterialIcon name="chevron_right" />
544
- </Button>
545
- </div>
546
- )}
547
- </>
548
- )}
549
-
550
- <PageTour storageKey={PACKAGES_TOUR_KEY} autoDelay={600} steps={packagesPageSteps} />
551
- <TourHelpButton storageKey={PACKAGES_TOUR_KEY} />
552
- </div>
553
- );
554
- }
555
-
556
- // ── Packages page tour ──
557
- const PACKAGES_TOUR_KEY = "labas-page-tour-packages";
558
- const packagesPageSteps: Step[] = [
559
- {
560
- target: "[data-tour='packages-header']",
561
- title: "Paket Soal",
562
- content: "Temukan paket soal dari komunitas atau lihat paket buatan sendiri. Klik 'Buat Paket' untuk membuat paket baru dari Bank Soal.",
563
- spotlightPadding: 8,
564
- },
565
- {
566
- target: "[data-tour='packages-filters']",
567
- title: "Filter & Pencarian",
568
- content: "Cari paket berdasarkan nama atau filter berdasarkan jenis ujian (IELTS, TOEFL, dll). Bisa juga switch antara 'Semua Paket' dan 'Paket Saya'.",
569
- spotlightPadding: 8,
570
- },
571
- {
572
- target: "[data-tour='packages-list']",
573
- title: "Mulai Latihan",
574
- content: "Klik kartu paket untuk lihat detail, atau langsung klik 'Mulai Latihan' untuk mengerjakan soal. Pantau skor dan progres kamu!",
575
- spotlightPadding: 8,
576
- },
577
- ];
578
-
579
- function VisChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
580
- return (
581
- <button
582
- onClick={onClick}
583
- className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
584
- active
585
- ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
586
- : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
587
- }`}
588
- >
589
- {children}
590
- </button>
591
- );
592
- }
 
1
+ import { createFileRoute, redirect } from "@tanstack/react-router";
 
 
2
  import { z } from "zod";
3
  import { authClient } from "@/lib/auth-client";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export const Route = createFileRoute("/packages")({
 
6
  validateSearch: z.object({
7
  tab: z.enum(["all", "mine"]).optional(),
8
  search: z.string().optional(),
 
18
  return { session };
19
  },
20
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/web/src/routes/settings.lazy.tsx ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createLazyFileRoute } from "@tanstack/react-router";
2
+ import { RouteComponent } from "@/components/routes/SettingsPage";
3
+
4
+ export const Route = createLazyFileRoute("/settings")({
5
+ component: RouteComponent,
6
+ });
apps/web/src/routes/settings.tsx CHANGED
@@ -1,27 +1,8 @@
1
- import { useState, startTransition } from "react";
2
- import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
- import { useQuery } from "@tanstack/react-query";
4
- import { authClient } from "@/lib/auth-client";
5
- import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
6
- import { trpc } from "@/utils/trpc";
7
- import {
8
- Tabs,
9
- TabsList,
10
- TabsTrigger,
11
- TabsContent,
12
- } from "@labas/ui/components/tabs";
13
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
14
- import { MaterialIcon } from "@/components/ui/MaterialIcon";
15
- import { TokenUsageChart } from "@/components/settings/TokenUsageChart";
16
- import { ApiKeyList } from "@/components/settings/ApiKeyList";
17
- import { ApiKeyForm } from "@/components/settings/ApiKeyForm";
18
- import { TipsCard } from "@/components/settings/TipsCard";
19
- import { SecurityInfo } from "@/components/settings/SecurityInfo";
20
- import { AccountSettings } from "@/components/settings/AccountSettings";
21
  import { z } from "zod";
 
22
 
23
  export const Route = createFileRoute("/settings")({
24
- component: RouteComponent,
25
  validateSearch: z.object({
26
  tab: z.enum(["api-keys", "token-usage", "security", "account"]).optional(),
27
  }).parse,
@@ -33,320 +14,3 @@ export const Route = createFileRoute("/settings")({
33
  return { session };
34
  },
35
  });
36
-
37
- const PROVIDERS = [
38
- { value: "openai", label: "OpenAI" },
39
- { value: "anthropic", label: "Anthropic" },
40
- { value: "google", label: "Google" },
41
- { value: "openrouter", label: "OpenRouter" },
42
- { value: "groq", label: "Groq" },
43
- { value: "custom", label: "Custom" },
44
- ];
45
-
46
- function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
47
- return {
48
- name: "",
49
- provider: "openai",
50
- baseUrl: "https://api.openai.com/v1",
51
- modelName: "gpt-4o-mini",
52
- maxTokens: 16384,
53
- };
54
- }
55
-
56
- type Tab = "api-keys" | "token-usage" | "security" | "account";
57
-
58
- function TokenUsageSection() {
59
- const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
60
-
61
- const totalTokens = data?.totalTokens ?? 0;
62
- const jobs = data?.jobs ?? [];
63
-
64
- return (
65
- <div className="space-y-6">
66
- <TokenUsageChart />
67
-
68
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
69
- <CardHeader>
70
- <div className="flex items-center gap-3">
71
- <MaterialIcon name="toll" className="text-xl" />
72
- <div>
73
- <CardTitle className="font-headline text-[var(--clay-black)]">
74
- Penggunaan Token Hari Ini
75
- </CardTitle>
76
- <CardDescription className="text-[var(--warm-charcoal)]">
77
- Total token yang terpakai untuk generate soal hari ini.
78
- </CardDescription>
79
- </div>
80
- </div>
81
- </CardHeader>
82
- <CardContent className="space-y-6">
83
- <div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
84
- <MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
85
- <div>
86
- <p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
87
- <p className="text-3xl font-extrabold text-[var(--matcha-800)]">
88
- {isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
89
- </p>
90
- </div>
91
- </div>
92
-
93
- <div>
94
- <h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
95
- Riwayat Generate Hari Ini
96
- </h3>
97
- {isLoading ? (
98
- <div className="space-y-2">
99
- {[1, 2, 3].map((i) => (
100
- <div key={i} className="h-12 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
101
- ))}
102
- </div>
103
- ) : jobs.length === 0 ? (
104
- <div className="text-center py-8 border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-lg)]">
105
- <MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
106
- <p className="text-[var(--warm-charcoal)] font-semibold">Belum ada generate hari ini</p>
107
- <p className="text-xs text-[var(--warm-silver)] mt-1">
108
- Generate soal baru untuk melihat penggunaan token.
109
- </p>
110
- </div>
111
- ) : (
112
- <div className="overflow-x-auto">
113
- <table className="w-full text-sm">
114
- <thead>
115
- <tr className="border-b border-[var(--oat-border)] text-[var(--warm-charcoal)]">
116
- <th className="text-left py-2 px-3 font-medium">Waktu</th>
117
- <th className="text-left py-2 px-3 font-medium">Mode</th>
118
- <th className="text-left py-2 px-3 font-medium">Status</th>
119
- <th className="text-left py-2 px-3 font-medium">Ujian</th>
120
- <th className="text-left py-2 px-3 font-medium">Section</th>
121
- <th className="text-right py-2 px-3 font-medium">Soal</th>
122
- <th className="text-right py-2 px-3 font-medium">Token</th>
123
- </tr>
124
- </thead>
125
- <tbody>
126
- {jobs.map((job) => {
127
- const isFailed = job.status === "failed";
128
- const isCancelled = job.status === "cancelled";
129
- return (
130
- <tr
131
- key={job.id}
132
- className={`border-b border-[var(--oat-border)] last:border-0 hover:bg-[var(--warm-cream)] transition-colors ${
133
- isFailed || isCancelled ? "opacity-70" : ""
134
- }`}
135
- >
136
- <td className="py-2.5 px-3 text-[var(--clay-black)] whitespace-nowrap">
137
- {new Date(job.createdAt).toLocaleTimeString("id-ID", {
138
- hour: "2-digit",
139
- minute: "2-digit",
140
- })}
141
- </td>
142
- <td className="py-2.5 px-3">
143
- <span
144
- className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
145
- job.mode === "agentic"
146
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
147
- : "bg-[var(--lavender-300)] text-[var(--lavender-800)]"
148
- }`}
149
- >
150
- {job.mode === "agentic" ? "Agentic" : "Quick"}
151
- </span>
152
- </td>
153
- <td className="py-2.5 px-3">
154
- {job.status === "completed" ? (
155
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--matcha-800)]">
156
- <MaterialIcon name="check_circle" className="text-xs" />
157
- Selesai
158
- </span>
159
- ) : isFailed ? (
160
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--pomegranate-400)]">
161
- <MaterialIcon name="error" className="text-xs" />
162
- Gagal
163
- </span>
164
- ) : isCancelled ? (
165
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-charcoal)]">
166
- <MaterialIcon name="cancel" className="text-xs" />
167
- Dibatalkan
168
- </span>
169
- ) : (
170
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-silver)]">
171
- <MaterialIcon name="hourglass_empty" className="text-xs" />
172
- {job.status}
173
- </span>
174
- )}
175
- </td>
176
- <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.examTypeId}</td>
177
- <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.sectionTypeId}</td>
178
- <td className="py-2.5 px-3 text-right text-[var(--clay-black)]">{job.questionCount}</td>
179
- <td className="py-2.5 px-3 text-right text-[var(--clay-black)] font-medium">
180
- {job.tokensUsed?.toLocaleString("id-ID") ?? "-"}
181
- </td>
182
- </tr>
183
- );
184
- })}
185
- </tbody>
186
- </table>
187
- </div>
188
- )}
189
- </div>
190
- </CardContent>
191
- </Card>
192
- </div>
193
- );
194
- }
195
-
196
- function RouteComponent() {
197
- const { configs, isLoading, addConfig, updateConfig, removeConfig } =
198
- useApiKeys();
199
-
200
- const search = Route.useSearch();
201
- const navigate = Route.useNavigate();
202
-
203
- const [editingId, setEditingId] = useState<string | null>(null);
204
- const [isAdding, setIsAdding] = useState(false);
205
- const [isSaving, setIsSaving] = useState(false);
206
-
207
- const activeTab: Tab = search.tab ?? "api-keys";
208
-
209
- const setTab = (tab: string) => {
210
- startTransition(() => {
211
- navigate({ search: { tab: tab as Tab } });
212
- });
213
- };
214
-
215
- const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>(
216
- () => ({
217
- ...defaultConfig(),
218
- apiKey: "",
219
- }),
220
- );
221
-
222
- const resetForm = () => {
223
- setForm({ ...defaultConfig(), apiKey: "" });
224
- };
225
-
226
- const handleFormChange = (field: keyof typeof form, value: string | number) => {
227
- setForm((prev) => ({ ...prev, [field]: value }));
228
- };
229
-
230
- const startAdd = () => {
231
- resetForm();
232
- setIsAdding(true);
233
- setEditingId(null);
234
- };
235
-
236
- const startEdit = (config: ApiKeyConfig) => {
237
- setForm({
238
- name: config.name,
239
- provider: config.provider,
240
- baseUrl: config.baseUrl,
241
- modelName: config.modelName,
242
- maxTokens: config.maxTokens ?? 16384,
243
- apiKey: "",
244
- });
245
- setEditingId(config.id);
246
- setIsAdding(false);
247
- };
248
-
249
- const cancelEdit = () => {
250
- setEditingId(null);
251
- setIsAdding(false);
252
- resetForm();
253
- };
254
-
255
- const handleSave = async () => {
256
- if (!form.name.trim()) return;
257
- setIsSaving(true);
258
- try {
259
- if (isAdding) {
260
- if (!form.apiKey) return;
261
- await addConfig(form);
262
- setIsAdding(false);
263
- } else if (editingId) {
264
- await updateConfig(editingId, form);
265
- setEditingId(null);
266
- }
267
- resetForm();
268
- } finally {
269
- setIsSaving(false);
270
- }
271
- };
272
-
273
- const isFormOpen = isAdding || editingId !== null;
274
- const canSave =
275
- !!form.name.trim() &&
276
- !!form.baseUrl.trim() &&
277
- !!form.modelName.trim() &&
278
- (isAdding ? !!form.apiKey : true);
279
-
280
- return (
281
- <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)]">
282
- <section className="mb-10">
283
- <div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
284
- <Link to="/" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
285
- <MaterialIcon name="chevron_right" className="text-xs" />
286
- <span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
287
- </div>
288
- <h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
289
- Pengaturan
290
- </h1>
291
- <p className="text-lg text-[var(--warm-charcoal)] mt-2">
292
- Kelola API key, pantau penggunaan token, dan preferensi akun.
293
- </p>
294
- </section>
295
-
296
- <Tabs value={activeTab} onValueChange={setTab} className="w-full">
297
- <TabsList variant="line" className="mb-6">
298
- <TabsTrigger value="api-keys">API Keys</TabsTrigger>
299
- <TabsTrigger value="token-usage">Token Usage</TabsTrigger>
300
- <TabsTrigger value="account">Akun</TabsTrigger>
301
- <TabsTrigger value="security">Keamanan</TabsTrigger>
302
- </TabsList>
303
-
304
- <TabsContent value="api-keys" className="space-y-8">
305
- <div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
306
- <div className="lg:col-span-7 space-y-8">
307
- <ApiKeyList
308
- configs={configs}
309
- isLoading={isLoading}
310
- editingId={editingId}
311
- isFormOpen={isFormOpen}
312
- providers={PROVIDERS}
313
- onStartAdd={startAdd}
314
- onStartEdit={startEdit}
315
- onRemove={removeConfig}
316
- />
317
-
318
- {isFormOpen && (
319
- <ApiKeyForm
320
- isAdding={isAdding}
321
- isSaving={isSaving}
322
- form={form}
323
- canSave={canSave}
324
- providers={PROVIDERS}
325
- onChange={handleFormChange}
326
- onSave={handleSave}
327
- onCancel={cancelEdit}
328
- />
329
- )}
330
- </div>
331
-
332
- <div className="lg:col-span-5">
333
- <TipsCard />
334
- </div>
335
- </div>
336
- </TabsContent>
337
-
338
- <TabsContent value="token-usage">
339
- <TokenUsageSection />
340
- </TabsContent>
341
-
342
- <TabsContent value="account">
343
- <AccountSettings />
344
- </TabsContent>
345
-
346
- <TabsContent value="security">
347
- <SecurityInfo />
348
- </TabsContent>
349
- </Tabs>
350
- </div>
351
- );
352
- }
 
1
+ import { createFileRoute, redirect } from "@tanstack/react-router";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import { z } from "zod";
3
+ import { authClient } from "@/lib/auth-client";
4
 
5
  export const Route = createFileRoute("/settings")({
 
6
  validateSearch: z.object({
7
  tab: z.enum(["api-keys", "token-usage", "security", "account"]).optional(),
8
  }).parse,
 
14
  return { session };
15
  },
16
  });