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