feat: implement lazy loading for routes and introduce new pages for bank, generate, landing, packages, and settings. Update routeTree to support lazy loading of components, enhancing performance and user experience. Add necessary validation and session handling for new routes, ensuring secure access to features.
Browse files- apps/web/src/components/routes/BankPage.tsx +578 -0
- apps/web/src/components/routes/GeneratePage.tsx +770 -0
- apps/web/src/components/routes/LandingPage.tsx +295 -0
- apps/web/src/components/routes/PackagesPage.tsx +591 -0
- apps/web/src/components/routes/SettingsPage.tsx +351 -0
- apps/web/src/routeTree.gen.ts +5 -5
- apps/web/src/routes/bank.lazy.tsx +6 -0
- apps/web/src/routes/bank.tsx +0 -556
- apps/web/src/routes/generate.lazy.tsx +6 -0
- apps/web/src/routes/generate.tsx +1 -769
- apps/web/src/routes/landing.lazy.tsx +6 -0
- apps/web/src/routes/landing.tsx +1 -282
- apps/web/src/routes/packages.lazy.tsx +6 -0
- apps/web/src/routes/packages.tsx +1 -573
- apps/web/src/routes/settings.lazy.tsx +6 -0
- apps/web/src/routes/settings.tsx +2 -338
apps/web/src/components/routes/BankPage.tsx
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from "react";
|
| 2 |
+
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
+
import { authClient } from "@/lib/auth-client";
|
| 4 |
+
import { trpc, queryClient } from "@/utils/trpc";
|
| 5 |
+
import { EXAM_TYPES, SECTIONS } from "@/lib/exam-constants";
|
| 6 |
+
import { formatLabel } from "@/lib/format";
|
| 7 |
+
import { usePackageBuilder } from "@/hooks/use-package-builder";
|
| 8 |
+
import { useLocalStorageBoolean } from "@/hooks/use-local-storage-boolean";
|
| 9 |
+
import { AutoBundleModal } from "@/components/bank/AutoBundleModal";
|
| 10 |
+
import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
|
| 11 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 12 |
+
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 13 |
+
import { FilterBar } from "@/components/bank/FilterBar";
|
| 14 |
+
import { MobileFilterSheet } from "@/components/bank/MobileFilterSheet";
|
| 15 |
+
import { AdvancedFilters } from "@/components/bank/AdvancedFilters";
|
| 16 |
+
import { SoalBrowser } from "@/components/bank/SoalBrowser";
|
| 17 |
+
import { SectionBrowser } from "@/components/bank/SectionBrowser";
|
| 18 |
+
import { BundleSidebar } from "@/components/bank/BundleSidebar";
|
| 19 |
+
import { toast } from "sonner";
|
| 20 |
+
import type { Step } from "react-joyride";
|
| 21 |
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 22 |
+
import z from "zod";
|
| 23 |
+
|
| 24 |
+
const FILTER_ADVANCED_KEY = "labas-bank-filter-advanced";
|
| 25 |
+
|
| 26 |
+
export const Route = createFileRoute("/bank")({
|
| 27 |
+
validateSearch: z.object({
|
| 28 |
+
mode: z.enum(["soal", "section"]).optional(),
|
| 29 |
+
tab: z.enum(["mine", "public"]).optional(),
|
| 30 |
+
search: z.string().optional(),
|
| 31 |
+
examType: z.string().optional(),
|
| 32 |
+
section: z.string().optional(),
|
| 33 |
+
format: z.string().optional(),
|
| 34 |
+
difficulty: z.coerce.number().optional(),
|
| 35 |
+
visibility: z.enum(["all", "private", "public"]).optional(),
|
| 36 |
+
}).parse,
|
| 37 |
+
beforeLoad: async () => {
|
| 38 |
+
const session = await authClient.getSession();
|
| 39 |
+
if (!session.data) {
|
| 40 |
+
redirect({ to: "/login", throw: true });
|
| 41 |
+
}
|
| 42 |
+
return { session };
|
| 43 |
+
},
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
type QuestionTab = "mine" | "public";
|
| 47 |
+
type Mode = "soal" | "section";
|
| 48 |
+
|
| 49 |
+
export function BankComponent() {
|
| 50 |
+
const search = Route.useSearch();
|
| 51 |
+
const navigate = Route.useNavigate();
|
| 52 |
+
const { data: session } = authClient.useSession();
|
| 53 |
+
const userId = session?.user.id;
|
| 54 |
+
|
| 55 |
+
const mode: Mode = search.mode ?? "soal";
|
| 56 |
+
const tab: QuestionTab = search.tab ?? "public";
|
| 57 |
+
const searchText = search.search ?? "";
|
| 58 |
+
const examType = search.examType ?? "";
|
| 59 |
+
const section = search.section ?? "";
|
| 60 |
+
const format = search.format ?? "";
|
| 61 |
+
const difficulty = search.difficulty;
|
| 62 |
+
const visibilityFilter = search.visibility ?? "all";
|
| 63 |
+
|
| 64 |
+
// ── Infinite scroll state ──
|
| 65 |
+
const [allQuestions, setAllQuestions] = useState<any[]>([]);
|
| 66 |
+
const [offset, setOffset] = useState(0);
|
| 67 |
+
const limit = 12;
|
| 68 |
+
const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
|
| 69 |
+
|
| 70 |
+
// ── Sidebar / Bundle State ──
|
| 71 |
+
const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
|
| 72 |
+
const [bundleSections, setBundleSections] = useState<any[]>([]);
|
| 73 |
+
const [bundleTitle, setBundleTitle] = useState("");
|
| 74 |
+
const [bundleDescription, setBundleDescription] = useState("");
|
| 75 |
+
const [bundleIsPublic, setBundleIsPublic] = useState(false);
|
| 76 |
+
|
| 77 |
+
// ── Modals ──
|
| 78 |
+
const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
|
| 79 |
+
const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
|
| 80 |
+
|
| 81 |
+
// ── Filter UI State ──
|
| 82 |
+
const [isAdvancedOpen, setIsAdvancedOpen] = useLocalStorageBoolean(FILTER_ADVANCED_KEY, false);
|
| 83 |
+
const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false);
|
| 84 |
+
|
| 85 |
+
// ── Data Queries ──
|
| 86 |
+
const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
|
| 87 |
+
? { isPublic: visibilityFilter === "public" }
|
| 88 |
+
: {};
|
| 89 |
+
|
| 90 |
+
const questionQuery = useQuery(
|
| 91 |
+
trpc.question.list.queryOptions(
|
| 92 |
+
{
|
| 93 |
+
search: searchText || undefined,
|
| 94 |
+
examTypeId: examType || undefined,
|
| 95 |
+
sectionTypeId: section || undefined,
|
| 96 |
+
format: format || undefined,
|
| 97 |
+
difficulty,
|
| 98 |
+
...(tab === "mine" && userId
|
| 99 |
+
? { creatorUserId: userId, ...visibilityFilterParam }
|
| 100 |
+
: { isPublic: true }),
|
| 101 |
+
limit,
|
| 102 |
+
offset,
|
| 103 |
+
},
|
| 104 |
+
{ enabled: mode === "soal" },
|
| 105 |
+
),
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
// Reset offset when filters change
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
setOffset(0);
|
| 111 |
+
}, [filterKey]);
|
| 112 |
+
|
| 113 |
+
// Append / replace questions when query data arrives
|
| 114 |
+
useEffect(() => {
|
| 115 |
+
const data = questionQuery.data;
|
| 116 |
+
if (!data) return;
|
| 117 |
+
if (offset === 0) {
|
| 118 |
+
setAllQuestions(data.questions ?? []);
|
| 119 |
+
} else {
|
| 120 |
+
setAllQuestions((prev) => {
|
| 121 |
+
const existingIds = new Set(prev.map((q: any) => q.id));
|
| 122 |
+
const newQs = (data.questions ?? []).filter((q: any) => !existingIds.has(q.id));
|
| 123 |
+
return [...prev, ...newQs];
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
}, [questionQuery.data]);
|
| 127 |
+
|
| 128 |
+
const totalQuestions = questionQuery.data?.total ?? 0;
|
| 129 |
+
const hasMore = offset + limit < totalQuestions;
|
| 130 |
+
|
| 131 |
+
const sectionQuery = useQuery(
|
| 132 |
+
trpc.combo.availableSections.queryOptions(
|
| 133 |
+
{
|
| 134 |
+
examTypeId: examType || undefined,
|
| 135 |
+
search: searchText || undefined,
|
| 136 |
+
limit: 50,
|
| 137 |
+
offset: 0,
|
| 138 |
+
},
|
| 139 |
+
{ enabled: mode === "section" },
|
| 140 |
+
),
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
// ── Mutations ──
|
| 144 |
+
const { isPending: isPackagePending, handleAutoBundle } = usePackageBuilder();
|
| 145 |
+
|
| 146 |
+
const createPackage = useMutation(trpc.package.create.mutationOptions());
|
| 147 |
+
const addSection = useMutation(trpc.package.addSection.mutationOptions());
|
| 148 |
+
const addQuestion = useMutation(trpc.package.addQuestion.mutationOptions());
|
| 149 |
+
const createCombo = useMutation(trpc.combo.create.mutationOptions());
|
| 150 |
+
|
| 151 |
+
const togglePublic = useMutation({
|
| 152 |
+
...trpc.question.togglePublic.mutationOptions(),
|
| 153 |
+
onSuccess: () => questionQuery.refetch(),
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
const deleteQuestion = useMutation({
|
| 157 |
+
...trpc.question.delete.mutationOptions(),
|
| 158 |
+
onSuccess: () => questionQuery.refetch(),
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
const bulkPublish = useMutation({
|
| 162 |
+
...trpc.question.bulkPublish.mutationOptions(),
|
| 163 |
+
onSuccess: (data) => {
|
| 164 |
+
questionQuery.refetch();
|
| 165 |
+
if (data.skipped > 0) {
|
| 166 |
+
toast.success(
|
| 167 |
+
`${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
|
| 168 |
+
{ description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
|
| 169 |
+
);
|
| 170 |
+
} else {
|
| 171 |
+
toast.success(`${data.updated} soal berhasil dipublikasikan`);
|
| 172 |
+
}
|
| 173 |
+
},
|
| 174 |
+
onError: (err: any) => {
|
| 175 |
+
toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: err.message });
|
| 176 |
+
},
|
| 177 |
+
});
|
| 178 |
+
|
| 179 |
+
// ── Navigation helpers ──
|
| 180 |
+
const setMode = (newMode: Mode) => {
|
| 181 |
+
navigate({
|
| 182 |
+
search: {
|
| 183 |
+
mode: newMode,
|
| 184 |
+
tab: newMode === "soal" ? "mine" : undefined,
|
| 185 |
+
search: "",
|
| 186 |
+
examType: "",
|
| 187 |
+
section: "",
|
| 188 |
+
format: "",
|
| 189 |
+
difficulty: undefined,
|
| 190 |
+
},
|
| 191 |
+
});
|
| 192 |
+
if (newMode === "soal") setBundleSections([]);
|
| 193 |
+
else setBundleQuestions([]);
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const setSearch = (value: string) =>
|
| 197 |
+
navigate({ search: (prev) => ({ ...prev, search: value }) });
|
| 198 |
+
|
| 199 |
+
const setExamType = (value: string) =>
|
| 200 |
+
navigate({ search: (prev) => ({ ...prev, examType: value }) });
|
| 201 |
+
|
| 202 |
+
const setSection = (value: string) =>
|
| 203 |
+
navigate({ search: (prev) => ({ ...prev, section: value }) });
|
| 204 |
+
|
| 205 |
+
const setFormat = (value: string) =>
|
| 206 |
+
navigate({ search: (prev) => ({ ...prev, format: value }) });
|
| 207 |
+
|
| 208 |
+
const setDifficulty = (value: number | undefined) =>
|
| 209 |
+
navigate({ search: (prev) => ({ ...prev, difficulty: value }) });
|
| 210 |
+
|
| 211 |
+
const setVisibility = (value: "all" | "private" | "public") =>
|
| 212 |
+
navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value }) });
|
| 213 |
+
|
| 214 |
+
const setTab = (newTab: QuestionTab) =>
|
| 215 |
+
navigate({
|
| 216 |
+
search: {
|
| 217 |
+
mode: "soal",
|
| 218 |
+
tab: newTab,
|
| 219 |
+
search: "",
|
| 220 |
+
examType: "",
|
| 221 |
+
section: "",
|
| 222 |
+
format: "",
|
| 223 |
+
difficulty: undefined,
|
| 224 |
+
visibility: undefined,
|
| 225 |
+
},
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
const clearFilters = () =>
|
| 229 |
+
navigate({
|
| 230 |
+
search: (prev) => ({
|
| 231 |
+
...prev,
|
| 232 |
+
search: "",
|
| 233 |
+
examType: "",
|
| 234 |
+
section: "",
|
| 235 |
+
format: "",
|
| 236 |
+
difficulty: undefined,
|
| 237 |
+
visibility: undefined,
|
| 238 |
+
}),
|
| 239 |
+
});
|
| 240 |
+
|
| 241 |
+
const hasFilters =
|
| 242 |
+
!!searchText || !!examType || !!section || !!format || difficulty !== undefined;
|
| 243 |
+
|
| 244 |
+
// ── Active filter chips data ──
|
| 245 |
+
const activeChips = [
|
| 246 |
+
...(examType ? [{ key: "examType", label: EXAM_TYPES.find((t) => t.id === examType)?.name ?? examType, onRemove: () => setExamType("") }] : []),
|
| 247 |
+
...(section ? [{ key: "section", label: SECTIONS.find((s) => s.id === section)?.name ?? section, onRemove: () => setSection("") }] : []),
|
| 248 |
+
...(format ? [{ key: "format", label: formatLabel(format), onRemove: () => setFormat("") }] : []),
|
| 249 |
+
...(difficulty !== undefined ? [{ key: "difficulty", label: `Lv.${difficulty}`, onRemove: () => setDifficulty(undefined) }] : []),
|
| 250 |
+
];
|
| 251 |
+
|
| 252 |
+
// ── Bundle helpers ──
|
| 253 |
+
const lockedExamType = bundleQuestions.length > 0 ? bundleQuestions[0]?.examTypeId : null;
|
| 254 |
+
|
| 255 |
+
const isQuestionInBundle = (qid: string) =>
|
| 256 |
+
bundleQuestions.some((q) => q.id === qid);
|
| 257 |
+
|
| 258 |
+
const isSectionInBundle = (sid: string) =>
|
| 259 |
+
bundleSections.some((s) => s.id === sid);
|
| 260 |
+
|
| 261 |
+
const toggleQuestion = (q: any) => {
|
| 262 |
+
if (lockedExamType && q.examTypeId !== lockedExamType) {
|
| 263 |
+
toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
|
| 264 |
+
return;
|
| 265 |
+
}
|
| 266 |
+
setBundleQuestions((prev) => {
|
| 267 |
+
const exists = prev.find((x) => x.id === q.id);
|
| 268 |
+
if (exists) return prev.filter((x) => x.id !== q.id);
|
| 269 |
+
return [...prev, q];
|
| 270 |
+
});
|
| 271 |
+
};
|
| 272 |
+
|
| 273 |
+
const toggleSection = (s: any) => {
|
| 274 |
+
setBundleSections((prev) => {
|
| 275 |
+
const exists = prev.find((x) => x.id === s.id);
|
| 276 |
+
if (exists) return prev.filter((x) => x.id !== s.id);
|
| 277 |
+
return [...prev, s];
|
| 278 |
+
});
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
const removeFromBundle = (id: string, type: "question" | "section") => {
|
| 282 |
+
if (type === "question") {
|
| 283 |
+
setBundleQuestions((prev) => prev.filter((x) => x.id !== id));
|
| 284 |
+
} else {
|
| 285 |
+
setBundleSections((prev) => prev.filter((x) => x.id !== id));
|
| 286 |
+
}
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
// ── Create handlers ──
|
| 290 |
+
const handleCreateFromQuestions = async () => {
|
| 291 |
+
if (!bundleTitle || bundleQuestions.length === 0) return;
|
| 292 |
+
const first = bundleQuestions[0];
|
| 293 |
+
try {
|
| 294 |
+
const pkg = await createPackage.mutateAsync({
|
| 295 |
+
title: bundleTitle,
|
| 296 |
+
description: bundleDescription,
|
| 297 |
+
examTypeId: first?.examTypeId ?? "",
|
| 298 |
+
isPublic: bundleIsPublic,
|
| 299 |
+
estimatedDurationMin: bundleQuestions.length * 2,
|
| 300 |
+
});
|
| 301 |
+
const sec = await addSection.mutateAsync({
|
| 302 |
+
packageId: pkg.id,
|
| 303 |
+
sectionTypeId: first?.sectionTypeId ?? "READING",
|
| 304 |
+
title: `${first?.sectionTypeName ?? "Reading"} Section`,
|
| 305 |
+
orderIndex: 0,
|
| 306 |
+
});
|
| 307 |
+
for (let i = 0; i < bundleQuestions.length; i++) {
|
| 308 |
+
await addQuestion.mutateAsync({
|
| 309 |
+
sectionId: sec.id,
|
| 310 |
+
questionId: bundleQuestions[i].id,
|
| 311 |
+
orderIndex: i,
|
| 312 |
+
});
|
| 313 |
+
}
|
| 314 |
+
setBundleQuestions([]);
|
| 315 |
+
setBundleTitle("");
|
| 316 |
+
setBundleDescription("");
|
| 317 |
+
} catch (err: any) {
|
| 318 |
+
toast.error("Gagal membuat paket", { description: err.message });
|
| 319 |
+
}
|
| 320 |
+
};
|
| 321 |
+
|
| 322 |
+
const handleCreateFromSections = async () => {
|
| 323 |
+
if (!bundleTitle || bundleSections.length === 0) return;
|
| 324 |
+
try {
|
| 325 |
+
await createCombo.mutateAsync({
|
| 326 |
+
title: bundleTitle,
|
| 327 |
+
description: bundleDescription,
|
| 328 |
+
isPublic: bundleIsPublic,
|
| 329 |
+
sections: bundleSections.map((s, i) => ({
|
| 330 |
+
sourcePackageId: s.packageId,
|
| 331 |
+
sourceSectionId: s.id,
|
| 332 |
+
orderIndex: i,
|
| 333 |
+
})),
|
| 334 |
+
});
|
| 335 |
+
setBundleSections([]);
|
| 336 |
+
setBundleTitle("");
|
| 337 |
+
setBundleDescription("");
|
| 338 |
+
toast.success("Combo paket berhasil dibuat!");
|
| 339 |
+
} catch (err: any) {
|
| 340 |
+
toast.error("Gagal membuat combo", { description: err.message });
|
| 341 |
+
}
|
| 342 |
+
};
|
| 343 |
+
|
| 344 |
+
// ── Auto Bundle ──
|
| 345 |
+
const autoBundleExamType = examType || lockedExamType || null;
|
| 346 |
+
const autoBundleSectionType = section || null;
|
| 347 |
+
|
| 348 |
+
const onAutoBundle = async (data: {
|
| 349 |
+
title: string;
|
| 350 |
+
description: string;
|
| 351 |
+
isPublic: boolean;
|
| 352 |
+
count: number;
|
| 353 |
+
sortOrder: "random" | "difficulty";
|
| 354 |
+
}) => {
|
| 355 |
+
const batchSize = 50;
|
| 356 |
+
const baseInput = {
|
| 357 |
+
search: searchText || undefined,
|
| 358 |
+
examTypeId: examType || undefined,
|
| 359 |
+
sectionTypeId: section || undefined,
|
| 360 |
+
format: format || undefined,
|
| 361 |
+
difficulty,
|
| 362 |
+
...(tab === "mine" && userId
|
| 363 |
+
? { creatorUserId: userId }
|
| 364 |
+
: { isPublic: true }),
|
| 365 |
+
limit: batchSize,
|
| 366 |
+
};
|
| 367 |
+
const firstPage = await queryClient.fetchQuery(
|
| 368 |
+
trpc.question.list.queryOptions({ ...baseInput, offset: 0 }),
|
| 369 |
+
);
|
| 370 |
+
const allQuestions = [...(firstPage.questions ?? [])];
|
| 371 |
+
const totalAvailable = firstPage.total ?? allQuestions.length;
|
| 372 |
+
for (let offset = batchSize; offset < totalAvailable; offset += batchSize) {
|
| 373 |
+
const pageData = await queryClient.fetchQuery(
|
| 374 |
+
trpc.question.list.queryOptions({ ...baseInput, offset }),
|
| 375 |
+
);
|
| 376 |
+
allQuestions.push(...(pageData.questions ?? []));
|
| 377 |
+
}
|
| 378 |
+
await handleAutoBundle({
|
| 379 |
+
...data,
|
| 380 |
+
examTypeId: autoBundleExamType ?? "",
|
| 381 |
+
sectionTypeId: autoBundleSectionType ?? "READING",
|
| 382 |
+
allQuestions,
|
| 383 |
+
});
|
| 384 |
+
setIsAutoBundleOpen(false);
|
| 385 |
+
};
|
| 386 |
+
|
| 387 |
+
// ── Render helpers ──
|
| 388 |
+
const questions = allQuestions;
|
| 389 |
+
|
| 390 |
+
const sections = sectionQuery.data?.sections ?? [];
|
| 391 |
+
const groupedSections = sections.reduce((groups: Record<string, any[]>, s: any) => {
|
| 392 |
+
const key = `${s.examTypeName ?? "Unknown"} — ${s.packageTitle ?? "Untitled"}`;
|
| 393 |
+
if (!groups[key]) groups[key] = [];
|
| 394 |
+
groups[key].push(s);
|
| 395 |
+
return groups;
|
| 396 |
+
}, {} as Record<string, any[]>);
|
| 397 |
+
|
| 398 |
+
const isCreating =
|
| 399 |
+
createPackage.isPending ||
|
| 400 |
+
addSection.isPending ||
|
| 401 |
+
addQuestion.isPending ||
|
| 402 |
+
createCombo.isPending;
|
| 403 |
+
|
| 404 |
+
return (
|
| 405 |
+
<div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
|
| 406 |
+
<FilterBar
|
| 407 |
+
mode={mode}
|
| 408 |
+
tab={tab}
|
| 409 |
+
searchText={searchText}
|
| 410 |
+
examType={examType}
|
| 411 |
+
visibility={visibilityFilter}
|
| 412 |
+
activeChips={activeChips}
|
| 413 |
+
hasFilters={hasFilters}
|
| 414 |
+
isAdvancedOpen={isAdvancedOpen}
|
| 415 |
+
lockedExamType={lockedExamType}
|
| 416 |
+
dataTour="bank-filters"
|
| 417 |
+
onToggleAdvanced={() => setIsAdvancedOpen((v) => !v)}
|
| 418 |
+
onSetMode={setMode}
|
| 419 |
+
onSetTab={setTab}
|
| 420 |
+
onSetSearch={setSearch}
|
| 421 |
+
onSetExamType={setExamType}
|
| 422 |
+
onSetVisibility={setVisibility}
|
| 423 |
+
onClearFilters={clearFilters}
|
| 424 |
+
onOpenMobileSheet={() => setIsMobileSheetOpen(true)}
|
| 425 |
+
advancedFilters={
|
| 426 |
+
<AdvancedFilters
|
| 427 |
+
section={section}
|
| 428 |
+
format={format}
|
| 429 |
+
difficulty={difficulty}
|
| 430 |
+
onSetSection={setSection}
|
| 431 |
+
onSetFormat={setFormat}
|
| 432 |
+
onSetDifficulty={setDifficulty}
|
| 433 |
+
/>
|
| 434 |
+
}
|
| 435 |
+
/>
|
| 436 |
+
|
| 437 |
+
<div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-6">
|
| 438 |
+
<section className="mb-8">
|
| 439 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 440 |
+
Buat Paket
|
| 441 |
+
</h1>
|
| 442 |
+
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 443 |
+
Pilih soal atau section untuk dibuatkan paket latihan.
|
| 444 |
+
</p>
|
| 445 |
+
<div className="mt-3 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
|
| 446 |
+
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
|
| 447 |
+
<MaterialIcon name="auto_awesome" className="text-sm" />
|
| 448 |
+
Auto Bundle — biarkan AI pilihkan soal otomatis
|
| 449 |
+
</span>
|
| 450 |
+
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
|
| 451 |
+
<MaterialIcon name="touch_app" className="text-sm" />
|
| 452 |
+
Manual — pilih sendiri soal satu per satu
|
| 453 |
+
</span>
|
| 454 |
+
</div>
|
| 455 |
+
</section>
|
| 456 |
+
|
| 457 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 458 |
+
<div data-tour="bank-questions" className="lg:col-span-8">
|
| 459 |
+
{mode === "soal" ? (
|
| 460 |
+
<SoalBrowser
|
| 461 |
+
isLoading={questionQuery.isLoading}
|
| 462 |
+
questions={questions}
|
| 463 |
+
hasMore={hasMore}
|
| 464 |
+
isFetchingNextPage={questionQuery.isFetching}
|
| 465 |
+
onLoadMore={() => setOffset((prev) => prev + limit)}
|
| 466 |
+
hasFilters={hasFilters}
|
| 467 |
+
userId={userId}
|
| 468 |
+
lockedExamType={lockedExamType}
|
| 469 |
+
tab={tab}
|
| 470 |
+
filterKey={filterKey}
|
| 471 |
+
isQuestionInBundle={isQuestionInBundle}
|
| 472 |
+
onToggleQuestion={toggleQuestion}
|
| 473 |
+
onOpenDetail={setSelectedQuestion}
|
| 474 |
+
onTogglePublic={(id) => togglePublic.mutate({ id })}
|
| 475 |
+
onDelete={(id) => {
|
| 476 |
+
if (confirm("Yakin mau hapus soal ini?")) deleteQuestion.mutate({ id });
|
| 477 |
+
}}
|
| 478 |
+
onClearFilters={clearFilters}
|
| 479 |
+
onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
|
| 480 |
+
onPublishAllPrivate={(ids) => bulkPublish.mutate({ ids })}
|
| 481 |
+
/>
|
| 482 |
+
) : (
|
| 483 |
+
<SectionBrowser
|
| 484 |
+
isLoading={sectionQuery.isLoading}
|
| 485 |
+
groupedSections={groupedSections}
|
| 486 |
+
isSectionInBundle={isSectionInBundle}
|
| 487 |
+
onToggleSection={toggleSection}
|
| 488 |
+
/>
|
| 489 |
+
)}
|
| 490 |
+
</div>
|
| 491 |
+
|
| 492 |
+
<BundleSidebar
|
| 493 |
+
mode={mode}
|
| 494 |
+
bundleQuestions={bundleQuestions}
|
| 495 |
+
bundleSections={bundleSections}
|
| 496 |
+
bundleTitle={bundleTitle}
|
| 497 |
+
bundleDescription={bundleDescription}
|
| 498 |
+
bundleIsPublic={bundleIsPublic}
|
| 499 |
+
isCreating={isCreating}
|
| 500 |
+
autoBundleExamType={autoBundleExamType}
|
| 501 |
+
lockedExamType={lockedExamType}
|
| 502 |
+
onSetTitle={setBundleTitle}
|
| 503 |
+
onSetDescription={setBundleDescription}
|
| 504 |
+
onSetIsPublic={setBundleIsPublic}
|
| 505 |
+
onRemoveFromBundle={removeFromBundle}
|
| 506 |
+
onCreateFromQuestions={handleCreateFromQuestions}
|
| 507 |
+
onCreateFromSections={handleCreateFromSections}
|
| 508 |
+
onOpenAutoBundle={() => setIsAutoBundleOpen(true)}
|
| 509 |
+
/>
|
| 510 |
+
</div>
|
| 511 |
+
</div>
|
| 512 |
+
|
| 513 |
+
<MobileFilterSheet
|
| 514 |
+
open={isMobileSheetOpen}
|
| 515 |
+
onOpenChange={setIsMobileSheetOpen}
|
| 516 |
+
section={section}
|
| 517 |
+
format={format}
|
| 518 |
+
difficulty={difficulty}
|
| 519 |
+
activeChips={activeChips}
|
| 520 |
+
onSetSection={setSection}
|
| 521 |
+
onSetFormat={setFormat}
|
| 522 |
+
onSetDifficulty={setDifficulty}
|
| 523 |
+
onClearFilters={clearFilters}
|
| 524 |
+
/>
|
| 525 |
+
|
| 526 |
+
{selectedQuestion && (
|
| 527 |
+
<QuestionDetailModal
|
| 528 |
+
question={selectedQuestion}
|
| 529 |
+
onClose={() => setSelectedQuestion(null)}
|
| 530 |
+
isSelected={isQuestionInBundle(selectedQuestion.id)}
|
| 531 |
+
onToggleSelect={() => toggleQuestion(selectedQuestion)}
|
| 532 |
+
isSelectable={true}
|
| 533 |
+
/>
|
| 534 |
+
)}
|
| 535 |
+
|
| 536 |
+
{isAutoBundleOpen && autoBundleExamType && (
|
| 537 |
+
<AutoBundleModal
|
| 538 |
+
availableCount={totalQuestions}
|
| 539 |
+
examTypeName={EXAM_TYPES.find((t) => t.id === autoBundleExamType)?.name ?? autoBundleExamType}
|
| 540 |
+
sectionTypeName={SECTIONS.find((s) => s.id === autoBundleSectionType)?.name ?? "Reading"}
|
| 541 |
+
onClose={() => setIsAutoBundleOpen(false)}
|
| 542 |
+
onCreate={onAutoBundle}
|
| 543 |
+
isPending={isPackagePending}
|
| 544 |
+
/>
|
| 545 |
+
)}
|
| 546 |
+
|
| 547 |
+
<PageTour
|
| 548 |
+
storageKey={BANK_TOUR_KEY}
|
| 549 |
+
autoDelay={600}
|
| 550 |
+
steps={bankPageSteps}
|
| 551 |
+
/>
|
| 552 |
+
<TourHelpButton storageKey={BANK_TOUR_KEY} />
|
| 553 |
+
</div>
|
| 554 |
+
);
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
// ── Bank page tour ──
|
| 558 |
+
const BANK_TOUR_KEY = "labas-page-tour-bank";
|
| 559 |
+
const bankPageSteps: Step[] = [
|
| 560 |
+
{
|
| 561 |
+
target: "[data-tour='bank-filters']",
|
| 562 |
+
title: "Filter & Mode",
|
| 563 |
+
content: "Pilih mode 'Dari Soal' untuk pilih soal satu per satu, atau 'Dari Section' untuk gabung section dari paket yang sudah ada. Filter juga berdasarkan exam type dan kata kunci.",
|
| 564 |
+
spotlightPadding: 8,
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
target: "[data-tour='bank-questions']",
|
| 568 |
+
title: "Daftar Soal",
|
| 569 |
+
content: "Semua soal yang sesuai filter ditampilkan di sini. Klik soal untuk melihat detail, atau centang untuk menambahkannya ke paket.",
|
| 570 |
+
spotlightPadding: 8,
|
| 571 |
+
},
|
| 572 |
+
{
|
| 573 |
+
target: "[data-tour='bank-sidebar']",
|
| 574 |
+
title: "Sidebar Paket",
|
| 575 |
+
content: "Soal yang dipilih muncul di sini. Atur judul, deskripsi, dan visibilitas paket. Klik 'Auto Bundle' untuk isi otomatis, atau 'Buat Paket' untuk simpan.",
|
| 576 |
+
spotlightPadding: 8,
|
| 577 |
+
},
|
| 578 |
+
];
|
apps/web/src/components/routes/GeneratePage.tsx
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useRef } from "react";
|
| 2 |
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { Link } from "@tanstack/react-router";
|
| 4 |
+
import { authClient } from "@/lib/auth-client";
|
| 5 |
+
import { trpc } from "@/utils/trpc";
|
| 6 |
+
import { useApiKeys } from "@/hooks/use-api-key";
|
| 7 |
+
import { useGenerationJobs, type CompletedResult } from "@/hooks/use-generation-jobs";
|
| 8 |
+
import { Button } from "@labas/ui/components/button";
|
| 9 |
+
import {
|
| 10 |
+
Select,
|
| 11 |
+
SelectContent,
|
| 12 |
+
SelectItem,
|
| 13 |
+
SelectTrigger,
|
| 14 |
+
SelectValue,
|
| 15 |
+
} from "@labas/ui/components/select";
|
| 16 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 17 |
+
import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
|
| 18 |
+
import { ResultSection } from "@/components/generate/ResultSection";
|
| 19 |
+
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 20 |
+
import {
|
| 21 |
+
EXAM_TYPES,
|
| 22 |
+
SECTIONS,
|
| 23 |
+
FORMATS,
|
| 24 |
+
TOPICS,
|
| 25 |
+
DIFFICULTIES,
|
| 26 |
+
QUESTION_COUNT_PRESETS,
|
| 27 |
+
} from "@/lib/generate-constants";
|
| 28 |
+
import { getDifficultyLabel } from "@/lib/difficulty-mapping";
|
| 29 |
+
import "flag-icons/css/flag-icons.min.css";
|
| 30 |
+
import type { Step } from "react-joyride";
|
| 31 |
+
|
| 32 |
+
const MAX_PARALLEL = 3;
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
export function RouteComponent() {
|
| 36 |
+
const { configs, hasConfigs } = useApiKeys();
|
| 37 |
+
|
| 38 |
+
const [selectedKeyId, setSelectedKeyId] = useState<string>(
|
| 39 |
+
configs[0]?.id ?? "",
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
if (configs.length > 0 && !configs.find((c) => c.id === selectedKeyId)) {
|
| 44 |
+
setSelectedKeyId(configs[0].id);
|
| 45 |
+
}
|
| 46 |
+
}, [configs, selectedKeyId]);
|
| 47 |
+
|
| 48 |
+
const selectedConfig = configs.find((c) => c.id === selectedKeyId);
|
| 49 |
+
const [useFreeCredits, setUseFreeCredits] = useState(false);
|
| 50 |
+
|
| 51 |
+
const myCredit = useQuery(
|
| 52 |
+
trpc.admin.getMyCredit.queryOptions(),
|
| 53 |
+
);
|
| 54 |
+
const hasFreeCredits = myCredit.data?.freeCreditsEnabled === true;
|
| 55 |
+
const tokenBalance = myCredit.data?.tokenBalance ?? 0;
|
| 56 |
+
|
| 57 |
+
const {
|
| 58 |
+
activeCount,
|
| 59 |
+
completedResults,
|
| 60 |
+
isGenerating,
|
| 61 |
+
error,
|
| 62 |
+
addJob,
|
| 63 |
+
removeJob,
|
| 64 |
+
resetAll,
|
| 65 |
+
setError,
|
| 66 |
+
} = useGenerationJobs();
|
| 67 |
+
|
| 68 |
+
const [activeTabIdx, setActiveTabIdx] = useState(0);
|
| 69 |
+
const prevResultsLengthRef = useRef(0);
|
| 70 |
+
|
| 71 |
+
// Auto-scroll to results when they appear
|
| 72 |
+
const resultsRef = useRef<HTMLDivElement>(null);
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
if (completedResults.length > 0 && resultsRef.current) {
|
| 75 |
+
resultsRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 76 |
+
}
|
| 77 |
+
}, [completedResults.length]);
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
const prev = prevResultsLengthRef.current;
|
| 81 |
+
prevResultsLengthRef.current = completedResults.length;
|
| 82 |
+
|
| 83 |
+
if (completedResults.length === 0) {
|
| 84 |
+
setActiveTabIdx(0);
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (completedResults.length > prev) {
|
| 89 |
+
setActiveTabIdx(completedResults.length - 1);
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (activeTabIdx >= completedResults.length) {
|
| 94 |
+
setActiveTabIdx(completedResults.length - 1);
|
| 95 |
+
}
|
| 96 |
+
}, [completedResults.length]);
|
| 97 |
+
|
| 98 |
+
const [examType, setExamType] = useState("IELTS");
|
| 99 |
+
const [selectedSections, setSelectedSections] = useState<string[]>(["READING"]);
|
| 100 |
+
const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
|
| 101 |
+
const [difficulty, setDifficulty] = useState(2);
|
| 102 |
+
const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
|
| 103 |
+
const [questionCount, setQuestionCount] = useState(5);
|
| 104 |
+
const [weaknessAlign, setWeaknessAlign] = useState(75);
|
| 105 |
+
const [mode, setMode] = useState<"quick" | "agentic">("quick");
|
| 106 |
+
|
| 107 |
+
const isReadingAndWriting = selectedSections.includes("READING") && selectedSections.includes("WRITING");
|
| 108 |
+
|
| 109 |
+
useEffect(() => {
|
| 110 |
+
setSelectedFormats((prev) => {
|
| 111 |
+
const valid = prev.filter((f) =>
|
| 112 |
+
FORMATS.find((fmt) => fmt.id === f)?.allowedExams.includes(examType),
|
| 113 |
+
);
|
| 114 |
+
if (valid.length === 0) {
|
| 115 |
+
return ["multiple_choice"];
|
| 116 |
+
}
|
| 117 |
+
return valid;
|
| 118 |
+
});
|
| 119 |
+
}, [examType]);
|
| 120 |
+
|
| 121 |
+
useEffect(() => {
|
| 122 |
+
if (isReadingAndWriting) {
|
| 123 |
+
if (questionCount < 20) setQuestionCount(20);
|
| 124 |
+
if (mode === "quick") setMode("agentic");
|
| 125 |
+
}
|
| 126 |
+
}, [isReadingAndWriting]);
|
| 127 |
+
|
| 128 |
+
const generate = useMutation({
|
| 129 |
+
...trpc.ai.generate.mutationOptions(),
|
| 130 |
+
onSuccess: (data) => {
|
| 131 |
+
addJob(data.jobId);
|
| 132 |
+
setError(null);
|
| 133 |
+
},
|
| 134 |
+
onError: (err) => {
|
| 135 |
+
setError(err.message);
|
| 136 |
+
},
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
const toggleSection = (id: string) => {
|
| 140 |
+
setSelectedSections((prev) => {
|
| 141 |
+
if (prev.includes(id)) {
|
| 142 |
+
if (prev.length === 1) return prev;
|
| 143 |
+
return prev.filter((s) => s !== id);
|
| 144 |
+
}
|
| 145 |
+
return [...prev, id];
|
| 146 |
+
});
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const toggleFormat = (id: string) => {
|
| 150 |
+
setSelectedFormats((prev) =>
|
| 151 |
+
prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
|
| 152 |
+
);
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
const toggleTopic = (topic: string) => {
|
| 156 |
+
setSelectedTopics((prev) =>
|
| 157 |
+
prev.includes(topic) ? prev.filter((t) => t !== topic) : [...prev, topic],
|
| 158 |
+
);
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
const handleGenerate = () => {
|
| 162 |
+
if (!useFreeCredits && (!hasConfigs || !selectedConfig)) {
|
| 163 |
+
setError("API key belum dikonfigurasi. Tambahkan di Settings atau gunakan kredit gratis.");
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
if (selectedSections.length === 0) {
|
| 167 |
+
setError("Pilih minimal 1 section.");
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
if (selectedFormats.length === 0) {
|
| 171 |
+
setError("Pilih minimal 1 format soal.");
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const apiKeyConfig = useFreeCredits
|
| 176 |
+
? undefined
|
| 177 |
+
: {
|
| 178 |
+
baseUrl: selectedConfig!.baseUrl,
|
| 179 |
+
apiKey: selectedConfig!.apiKey,
|
| 180 |
+
model: selectedConfig!.modelName,
|
| 181 |
+
maxTokens: selectedConfig!.maxTokens ?? 16384,
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
+
generate.mutate({
|
| 185 |
+
examType: examType as any,
|
| 186 |
+
section: selectedSections[0] as any,
|
| 187 |
+
selectedSections: selectedSections as any,
|
| 188 |
+
formats: selectedFormats as any,
|
| 189 |
+
difficulty: difficulty + 1,
|
| 190 |
+
topics: selectedTopics,
|
| 191 |
+
questionCount,
|
| 192 |
+
mode,
|
| 193 |
+
apiKeyConfig,
|
| 194 |
+
} as any);
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
const sectionSplits = (() => {
|
| 198 |
+
if (mode !== "agentic" || questionCount < 20 || selectedSections.length <= 1) return null;
|
| 199 |
+
const base = Math.floor(questionCount / selectedSections.length);
|
| 200 |
+
const rem = questionCount % selectedSections.length;
|
| 201 |
+
return selectedSections.map((s, i) => ({ section: s, count: base + (i < rem ? 1 : 0) }));
|
| 202 |
+
})();
|
| 203 |
+
|
| 204 |
+
const activeResult = completedResults[activeTabIdx] ?? null;
|
| 205 |
+
|
| 206 |
+
return (
|
| 207 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
|
| 208 |
+
{/* Header */}
|
| 209 |
+
<section className="flex flex-col gap-2 relative mb-10">
|
| 210 |
+
<div className="absolute -left-8 -top-8 w-64 h-64 ai-glow pointer-events-none opacity-50" />
|
| 211 |
+
<h1 className="text-4xl md:text-5xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 212 |
+
AI Exam Generator
|
| 213 |
+
</h1>
|
| 214 |
+
<p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
|
| 215 |
+
Generate soal latihan dengan AI. Pilih exam, section, format, dan topik — sisanya AI yang kerjakan.
|
| 216 |
+
</p>
|
| 217 |
+
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
|
| 218 |
+
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
|
| 219 |
+
<MaterialIcon name="looks_one" className="text-sm" />
|
| 220 |
+
Pilih exam & section
|
| 221 |
+
</span>
|
| 222 |
+
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
|
| 223 |
+
<MaterialIcon name="looks_two" className="text-sm" />
|
| 224 |
+
Atur jumlah & format
|
| 225 |
+
</span>
|
| 226 |
+
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] font-medium">
|
| 227 |
+
<MaterialIcon name="looks_3" className="text-sm" />
|
| 228 |
+
Generate & simpan
|
| 229 |
+
</span>
|
| 230 |
+
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--clay-black)]/10 text-[var(--clay-black)] font-medium">
|
| 231 |
+
<MaterialIcon name="looks_4" className="text-sm" />
|
| 232 |
+
Buat paket dari Bank Soal
|
| 233 |
+
</span>
|
| 234 |
+
</div>
|
| 235 |
+
</section>
|
| 236 |
+
|
| 237 |
+
{!hasConfigs && !useFreeCredits && !hasFreeCredits && (
|
| 238 |
+
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
|
| 239 |
+
<MaterialIcon name="warning" />
|
| 240 |
+
<span>API key belum dikonfigurasi.</span>
|
| 241 |
+
<Link to="/settings" className="font-semibold underline">
|
| 242 |
+
Tambahkan di Settings →
|
| 243 |
+
</Link>
|
| 244 |
+
</div>
|
| 245 |
+
)}
|
| 246 |
+
|
| 247 |
+
{!hasConfigs && !useFreeCredits && hasFreeCredits && (
|
| 248 |
+
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] text-sm flex items-center gap-3 border-2 border-[var(--matcha-400)]">
|
| 249 |
+
<MaterialIcon name="tips_and_updates" />
|
| 250 |
+
<span>Belum ada API key. Kamu bisa pakai Free Credits!</span>
|
| 251 |
+
<button onClick={() => setUseFreeCredits(true)} className="font-semibold underline">
|
| 252 |
+
Gunakan Free Credits →
|
| 253 |
+
</button>
|
| 254 |
+
</div>
|
| 255 |
+
)}
|
| 256 |
+
|
| 257 |
+
<div className="mb-8 p-5 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
|
| 258 |
+
<div className="flex items-center justify-between mb-3">
|
| 259 |
+
<label className="text-sm font-medium text-[var(--clay-black)]">Generation Mode</label>
|
| 260 |
+
</div>
|
| 261 |
+
<div className="flex items-center gap-4">
|
| 262 |
+
<button
|
| 263 |
+
onClick={() => { setUseFreeCredits(false); }}
|
| 264 |
+
className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
|
| 265 |
+
!useFreeCredits
|
| 266 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)]"
|
| 267 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
|
| 268 |
+
}`}
|
| 269 |
+
>
|
| 270 |
+
<MaterialIcon name="vpn_key" className="text-sm" />
|
| 271 |
+
BYOK
|
| 272 |
+
</button>
|
| 273 |
+
{hasFreeCredits && (
|
| 274 |
+
<button
|
| 275 |
+
onClick={() => { setUseFreeCredits(true); }}
|
| 276 |
+
className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
|
| 277 |
+
useFreeCredits
|
| 278 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)]"
|
| 279 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
|
| 280 |
+
}`}
|
| 281 |
+
>
|
| 282 |
+
<MaterialIcon name="stars" className="text-sm" />
|
| 283 |
+
Free Credits
|
| 284 |
+
</button>
|
| 285 |
+
)}
|
| 286 |
+
{useFreeCredits && (
|
| 287 |
+
<Link to="/settings" className="text-xs text-[var(--matcha-600)] underline ml-2">
|
| 288 |
+
Atur BYOK di Settings
|
| 289 |
+
</Link>
|
| 290 |
+
)}
|
| 291 |
+
</div>
|
| 292 |
+
|
| 293 |
+
{useFreeCredits && myCredit.data && (
|
| 294 |
+
<div className="mt-4 pt-4 border-t border-[var(--oat-border)] space-y-2">
|
| 295 |
+
<div className="flex items-center justify-between">
|
| 296 |
+
<span className="text-sm text-[var(--warm-charcoal)]">Token kamu</span>
|
| 297 |
+
<span className={`text-lg font-headline font-bold ${tokenBalance > 0 ? "text-[var(--clay-black)]" : "text-[var(--clay-red)]"}`}>
|
| 298 |
+
{tokenBalance.toLocaleString()}
|
| 299 |
+
</span>
|
| 300 |
+
</div>
|
| 301 |
+
{tokenBalance > 0 && (
|
| 302 |
+
<div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
|
| 303 |
+
<div
|
| 304 |
+
className="h-full bg-[var(--matcha-500)] rounded-full transition-all"
|
| 305 |
+
style={{ width: `${Math.min(100, (tokenBalance / 50000) * 100)}%` }}
|
| 306 |
+
/>
|
| 307 |
+
</div>
|
| 308 |
+
)}
|
| 309 |
+
{myCredit.data.cooldownRemaining > 0 && (
|
| 310 |
+
<p className="flex items-center gap-1.5 text-xs text-[var(--sunbeam-800)] bg-[var(--sunbeam-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 311 |
+
<MaterialIcon name="schedule" className="text-base leading-none shrink-0" />
|
| 312 |
+
<span>
|
| 313 |
+
Cooldown: {myCredit.data.cooldownRemaining} hari lagi untuk auto-refill.
|
| 314 |
+
</span>
|
| 315 |
+
</p>
|
| 316 |
+
)}
|
| 317 |
+
{tokenBalance <= 0 && myCredit.data.cooldownRemaining === 0 && (
|
| 318 |
+
<p className="text-xs text-[var(--matcha-700)] bg-[var(--matcha-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 319 |
+
Token habis. Auto-refill tersedia saat kamu generate.
|
| 320 |
+
</p>
|
| 321 |
+
)}
|
| 322 |
+
{tokenBalance <= 0 && myCredit.data.cooldownRemaining > 0 && (
|
| 323 |
+
<p className="text-xs text-[var(--clay-red)]/80 bg-[var(--clay-red)]/5 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 324 |
+
Token habis & dalam cooldown. Gunakan BYOK atau tunggu {myCredit.data.cooldownRemaining} hari.
|
| 325 |
+
</p>
|
| 326 |
+
)}
|
| 327 |
+
</div>
|
| 328 |
+
)}
|
| 329 |
+
|
| 330 |
+
{!useFreeCredits && hasConfigs && (
|
| 331 |
+
<div className="mt-4 pt-4 border-t border-[var(--oat-border)]">
|
| 332 |
+
<label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">Provider / API Key</label>
|
| 333 |
+
<div className="flex gap-3">
|
| 334 |
+
<Select value={selectedKeyId} onValueChange={(v) => v && setSelectedKeyId(v)}>
|
| 335 |
+
<SelectTrigger className="flex-1 h-11">
|
| 336 |
+
<SelectValue>
|
| 337 |
+
{selectedConfig ? `${selectedConfig.name} · ${selectedConfig.modelName}` : "Pilih provider..."}
|
| 338 |
+
</SelectValue>
|
| 339 |
+
</SelectTrigger>
|
| 340 |
+
<SelectContent>
|
| 341 |
+
{configs.map((c) => (
|
| 342 |
+
<SelectItem key={c.id} value={c.id}>
|
| 343 |
+
{c.name} · {c.modelName}
|
| 344 |
+
</SelectItem>
|
| 345 |
+
))}
|
| 346 |
+
</SelectContent>
|
| 347 |
+
</Select>
|
| 348 |
+
<Link to="/settings">
|
| 349 |
+
<Button variant="outline" size="xl" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover">
|
| 350 |
+
<MaterialIcon name="settings" className="mr-1" />
|
| 351 |
+
Kelola
|
| 352 |
+
</Button>
|
| 353 |
+
</Link>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
)}
|
| 357 |
+
|
| 358 |
+
{!useFreeCredits && !hasConfigs && !hasFreeCredits && (
|
| 359 |
+
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 360 |
+
Tambahkan API key di Settings dahulu.
|
| 361 |
+
</p>
|
| 362 |
+
)}
|
| 363 |
+
|
| 364 |
+
{!useFreeCredits && !hasConfigs && hasFreeCredits && (
|
| 365 |
+
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 366 |
+
Belum ada API key?{" "}
|
| 367 |
+
<button onClick={() => setUseFreeCredits(true)} className="text-[var(--matcha-600)] underline">
|
| 368 |
+
Gunakan kredit gratis
|
| 369 |
+
</button>
|
| 370 |
+
{" "}atau tambah di Settings.
|
| 371 |
+
</p>
|
| 372 |
+
)}
|
| 373 |
+
|
| 374 |
+
{useFreeCredits && !hasFreeCredits && (
|
| 375 |
+
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 376 |
+
Free credits sedang dinonaktifkan oleh admin.
|
| 377 |
+
</p>
|
| 378 |
+
)}
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
|
| 382 |
+
{/* Configuration Panel */}
|
| 383 |
+
<div className="lg:col-span-8 flex flex-col gap-10">
|
| 384 |
+
|
| 385 |
+
{/* Exam Type */}
|
| 386 |
+
<div data-tour="generate-exam-type" className="flex flex-col gap-4">
|
| 387 |
+
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
|
| 388 |
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
| 389 |
+
{EXAM_TYPES.map((t) => (
|
| 390 |
+
<button
|
| 391 |
+
key={t.id}
|
| 392 |
+
onClick={() => setExamType(t.id)}
|
| 393 |
+
className={`flex items-center gap-3 py-4 px-4 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[56px] ${
|
| 394 |
+
examType === t.id
|
| 395 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
|
| 396 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
|
| 397 |
+
}`}
|
| 398 |
+
>
|
| 399 |
+
<span className={`fi fi-${t.code} w-6 h-4 rounded-sm shadow-sm shrink-0`} />
|
| 400 |
+
{t.name}
|
| 401 |
+
</button>
|
| 402 |
+
))}
|
| 403 |
+
</div>
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
{/* Section Selection — Multi-select */}
|
| 407 |
+
<div data-tour="generate-section" className="flex flex-col gap-4">
|
| 408 |
+
<div className="flex items-center justify-between">
|
| 409 |
+
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
|
| 410 |
+
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 411 |
+
{selectedSections.length} dipilih
|
| 412 |
+
</span>
|
| 413 |
+
</div>
|
| 414 |
+
<div className="flex flex-wrap gap-3">
|
| 415 |
+
{SECTIONS.map((s) => {
|
| 416 |
+
const isSelected = selectedSections.includes(s.id);
|
| 417 |
+
return (
|
| 418 |
+
<button
|
| 419 |
+
key={s.id}
|
| 420 |
+
onClick={() => toggleSection(s.id)}
|
| 421 |
+
className={`flex items-center gap-2.5 px-5 py-3 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[52px] ${
|
| 422 |
+
isSelected
|
| 423 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
|
| 424 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
|
| 425 |
+
}`}
|
| 426 |
+
>
|
| 427 |
+
<MaterialIcon
|
| 428 |
+
name={isSelected ? "check_circle" : s.icon}
|
| 429 |
+
className={`text-base shrink-0 ${isSelected ? "text-[var(--matcha-400)]" : ""}`}
|
| 430 |
+
/>
|
| 431 |
+
{s.name}
|
| 432 |
+
</button>
|
| 433 |
+
);
|
| 434 |
+
})}
|
| 435 |
+
</div>
|
| 436 |
+
{selectedSections.length > 1 && (
|
| 437 |
+
<p className="text-xs text-[var(--matcha-800)] bg-[var(--matcha-300)]/30 px-3 py-2 rounded-[var(--radius-md)]">
|
| 438 |
+
<MaterialIcon name="tips_and_updates" className="text-xs mr-1 inline" />
|
| 439 |
+
Kamu memilih {selectedSections.length} section. Mode Agentic dengan ≥20 soal akan otomatis membagi soal ke section yang dipilih.
|
| 440 |
+
</p>
|
| 441 |
+
)}
|
| 442 |
+
</div>
|
| 443 |
+
|
| 444 |
+
{/* Question Count */}
|
| 445 |
+
<div data-tour="generate-count" className="flex flex-col gap-4">
|
| 446 |
+
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">
|
| 447 |
+
Jumlah Soal
|
| 448 |
+
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
|
| 449 |
+
</label>
|
| 450 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
| 451 |
+
{QUESTION_COUNT_PRESETS.map((p) => {
|
| 452 |
+
const isDisabled = isReadingAndWriting && (p.value === 5 || p.value === 10);
|
| 453 |
+
return (
|
| 454 |
+
<button
|
| 455 |
+
key={p.value}
|
| 456 |
+
onClick={() => setQuestionCount(p.value)}
|
| 457 |
+
disabled={isDisabled}
|
| 458 |
+
className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover flex flex-col items-center gap-1 min-h-[72px] ${
|
| 459 |
+
isDisabled
|
| 460 |
+
? "bg-[var(--oat-light)] text-[var(--warm-silver)] cursor-not-allowed opacity-50 border-2 border-[var(--oat-border)]"
|
| 461 |
+
: questionCount === p.value
|
| 462 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 463 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
|
| 464 |
+
}`}
|
| 465 |
+
>
|
| 466 |
+
<span>{p.label}</span>
|
| 467 |
+
<span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
|
| 468 |
+
</button>
|
| 469 |
+
);
|
| 470 |
+
})}
|
| 471 |
+
</div>
|
| 472 |
+
<div className="flex items-center gap-3 mt-1">
|
| 473 |
+
<span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
|
| 474 |
+
<input
|
| 475 |
+
type="range"
|
| 476 |
+
min={isReadingAndWriting ? 20 : 1}
|
| 477 |
+
max={40}
|
| 478 |
+
value={questionCount}
|
| 479 |
+
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
| 480 |
+
className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
|
| 481 |
+
/>
|
| 482 |
+
<span className="text-xs font-bold text-[var(--clay-black)] w-6 text-right">{questionCount}</span>
|
| 483 |
+
</div>
|
| 484 |
+
|
| 485 |
+
{/* Auto Multi-Section Preview */}
|
| 486 |
+
{sectionSplits && (
|
| 487 |
+
<div className="mt-2 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 border border-[var(--matcha-400)]">
|
| 488 |
+
<div className="flex items-center gap-2 mb-2 text-[var(--matcha-800)] font-semibold text-sm">
|
| 489 |
+
<MaterialIcon name="auto_awesome" className="text-xs" />
|
| 490 |
+
Auto Multi-Section
|
| 491 |
+
</div>
|
| 492 |
+
<p className="text-[var(--matcha-800)]/80 text-xs mb-3">
|
| 493 |
+
Mode Agentic dengan {questionCount} soal akan dibagi ke {sectionSplits.length} section:
|
| 494 |
+
</p>
|
| 495 |
+
<div className="flex flex-wrap gap-2">
|
| 496 |
+
{sectionSplits.map((s) => {
|
| 497 |
+
const sec = SECTIONS.find((sec) => sec.id === s.section);
|
| 498 |
+
return (
|
| 499 |
+
<span
|
| 500 |
+
key={s.section}
|
| 501 |
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--pure-white)] text-[var(--matcha-800)] text-xs font-medium border border-[var(--matcha-400)]"
|
| 502 |
+
>
|
| 503 |
+
<MaterialIcon name={sec?.icon ?? "menu_book"} className="text-[10px]" />
|
| 504 |
+
{sec?.name ?? s.section}: {s.count} soal
|
| 505 |
+
</span>
|
| 506 |
+
);
|
| 507 |
+
})}
|
| 508 |
+
</div>
|
| 509 |
+
</div>
|
| 510 |
+
)}
|
| 511 |
+
</div>
|
| 512 |
+
|
| 513 |
+
{/* Difficulty */}
|
| 514 |
+
<div data-tour="generate-difficulty" className="flex flex-col gap-4">
|
| 515 |
+
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
|
| 516 |
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
| 517 |
+
{DIFFICULTIES.map((d, i) => (
|
| 518 |
+
<button
|
| 519 |
+
key={d}
|
| 520 |
+
onClick={() => setDifficulty(i)}
|
| 521 |
+
className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover min-h-[56px] flex flex-col items-center ${
|
| 522 |
+
difficulty === i
|
| 523 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 524 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
|
| 525 |
+
}`}
|
| 526 |
+
>
|
| 527 |
+
<span>{getDifficultyLabel(examType, i + 1)}</span>
|
| 528 |
+
<span className={`text-[10px] mt-0.5 ${difficulty === i ? "text-white/60" : "text-[var(--warm-silver)]"}`}>{d}</span>
|
| 529 |
+
</button>
|
| 530 |
+
))}
|
| 531 |
+
</div>
|
| 532 |
+
</div>
|
| 533 |
+
|
| 534 |
+
{/* Format Selection */}
|
| 535 |
+
<div data-tour="generate-format" className="flex flex-col gap-4">
|
| 536 |
+
<div className="flex items-center justify-between">
|
| 537 |
+
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
|
| 538 |
+
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 539 |
+
{selectedFormats.length} dipilih
|
| 540 |
+
</span>
|
| 541 |
+
</div>
|
| 542 |
+
<div className="flex flex-wrap gap-2">
|
| 543 |
+
{FORMATS.filter((f) => f.allowedExams.includes(examType)).map((f) => (
|
| 544 |
+
<button
|
| 545 |
+
key={f.id}
|
| 546 |
+
onClick={() => toggleFormat(f.id)}
|
| 547 |
+
className={`px-4 py-2.5 rounded-full text-sm font-medium flex items-center gap-2 cursor-pointer transition-all clay-hover min-h-[40px] ${
|
| 548 |
+
selectedFormats.includes(f.id)
|
| 549 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 550 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
|
| 551 |
+
}`}
|
| 552 |
+
>
|
| 553 |
+
{f.name}
|
| 554 |
+
{selectedFormats.includes(f.id) && (
|
| 555 |
+
<MaterialIcon name="close" className="text-sm" />
|
| 556 |
+
)}
|
| 557 |
+
</button>
|
| 558 |
+
))}
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
|
| 562 |
+
{/* Topic Focus */}
|
| 563 |
+
<div data-tour="generate-topic" className="flex flex-col gap-4">
|
| 564 |
+
<div className="flex items-center justify-between">
|
| 565 |
+
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
|
| 566 |
+
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 567 |
+
{selectedTopics.length} dipilih
|
| 568 |
+
</span>
|
| 569 |
+
</div>
|
| 570 |
+
<div className="flex flex-wrap gap-2">
|
| 571 |
+
{selectedTopics.map((topic) => (
|
| 572 |
+
<span
|
| 573 |
+
key={topic}
|
| 574 |
+
onClick={() => toggleTopic(topic)}
|
| 575 |
+
className="px-4 py-2 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] font-medium flex items-center gap-2 cursor-pointer transition-all hover:brightness-95 clay-hover min-h-[40px]"
|
| 576 |
+
>
|
| 577 |
+
{topic} <MaterialIcon name="close" className="text-sm" />
|
| 578 |
+
</span>
|
| 579 |
+
))}
|
| 580 |
+
{TOPICS.filter((t) => !selectedTopics.includes(t)).map((topic) => (
|
| 581 |
+
<button
|
| 582 |
+
key={topic}
|
| 583 |
+
onClick={() => toggleTopic(topic)}
|
| 584 |
+
className="px-4 py-2 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] font-medium hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)] transition-all clay-hover min-h-[40px]"
|
| 585 |
+
>
|
| 586 |
+
{topic}
|
| 587 |
+
</button>
|
| 588 |
+
))}
|
| 589 |
+
</div>
|
| 590 |
+
</div>
|
| 591 |
+
|
| 592 |
+
{/* Weakness Alignment */}
|
| 593 |
+
<div data-tour="generate-weakness" className="flex flex-col gap-4">
|
| 594 |
+
<div className="flex justify-between items-end">
|
| 595 |
+
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
|
| 596 |
+
<span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
|
| 597 |
+
Intelligent Focus
|
| 598 |
+
</span>
|
| 599 |
+
</div>
|
| 600 |
+
<div className="relative py-4">
|
| 601 |
+
<input
|
| 602 |
+
type="range"
|
| 603 |
+
min="0"
|
| 604 |
+
max="100"
|
| 605 |
+
value={weaknessAlign}
|
| 606 |
+
onChange={(e) => setWeaknessAlign(Number(e.target.value))}
|
| 607 |
+
className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
|
| 608 |
+
/>
|
| 609 |
+
<div className="flex justify-between mt-4 text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)]">
|
| 610 |
+
<span>Soal Seimbang</span>
|
| 611 |
+
<span>Fokus Kelemahan</span>
|
| 612 |
+
</div>
|
| 613 |
+
</div>
|
| 614 |
+
</div>
|
| 615 |
+
</div>
|
| 616 |
+
|
| 617 |
+
{/* Live Preview Card */}
|
| 618 |
+
<div data-tour="generate-blueprint" className="lg:col-span-4">
|
| 619 |
+
<TestBlueprintCard
|
| 620 |
+
examType={examType}
|
| 621 |
+
selectedSections={selectedSections}
|
| 622 |
+
selectedFormats={selectedFormats}
|
| 623 |
+
questionCount={questionCount}
|
| 624 |
+
weaknessAlign={weaknessAlign}
|
| 625 |
+
mode={mode}
|
| 626 |
+
setMode={setMode}
|
| 627 |
+
activeCount={activeCount}
|
| 628 |
+
maxParallel={MAX_PARALLEL}
|
| 629 |
+
generatePending={generate.isPending}
|
| 630 |
+
hasKey={hasConfigs || useFreeCredits}
|
| 631 |
+
error={error}
|
| 632 |
+
onGenerate={handleGenerate}
|
| 633 |
+
onDismissError={() => setError(null)}
|
| 634 |
+
disableQuick={isReadingAndWriting}
|
| 635 |
+
/>
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
|
| 639 |
+
{/* Results with Tabs */}
|
| 640 |
+
<div ref={resultsRef}>
|
| 641 |
+
{completedResults.length > 0 && (
|
| 642 |
+
<div className="mt-12">
|
| 643 |
+
<div className="flex items-center justify-between mb-4">
|
| 644 |
+
<h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
| 645 |
+
Hasil Generate
|
| 646 |
+
</h2>
|
| 647 |
+
<Button
|
| 648 |
+
variant="ghost"
|
| 649 |
+
size="sm"
|
| 650 |
+
onClick={resetAll}
|
| 651 |
+
className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)]"
|
| 652 |
+
>
|
| 653 |
+
<MaterialIcon name="delete_sweep" className="text-sm mr-1" />
|
| 654 |
+
Bersihkan
|
| 655 |
+
</Button>
|
| 656 |
+
</div>
|
| 657 |
+
|
| 658 |
+
{/* Tab Bar */}
|
| 659 |
+
<div className="flex gap-1 mb-6 border-b border-[var(--oat-border)] overflow-x-auto">
|
| 660 |
+
{completedResults.map((res, idx) => {
|
| 661 |
+
const questions = res.result?.questions ?? [];
|
| 662 |
+
const isActive = idx === activeTabIdx;
|
| 663 |
+
return (
|
| 664 |
+
<button
|
| 665 |
+
key={res.jobId}
|
| 666 |
+
onClick={() => setActiveTabIdx(idx)}
|
| 667 |
+
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
|
| 668 |
+
isActive
|
| 669 |
+
? "bg-[var(--pure-white)] text-[var(--clay-black)] border-[var(--clay-black)]"
|
| 670 |
+
: "text-[var(--warm-charcoal)] border-transparent hover:text-[var(--clay-black)] hover:bg-[var(--oat-light)]"
|
| 671 |
+
}`}
|
| 672 |
+
>
|
| 673 |
+
<MaterialIcon
|
| 674 |
+
name={questions.length > 0 ? "check_circle" : "sync"}
|
| 675 |
+
className={`text-sm ${isActive ? "text-[var(--matcha-400)]" : ""} ${questions.length === 0 && !isActive ? "animate-spin" : ""}`}
|
| 676 |
+
/>
|
| 677 |
+
<span>
|
| 678 |
+
{res.mode === "agentic" ? "Agentic" : "Quick"}
|
| 679 |
+
</span>
|
| 680 |
+
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 681 |
+
{questions.length} soal
|
| 682 |
+
</span>
|
| 683 |
+
<button
|
| 684 |
+
onClick={(e) => {
|
| 685 |
+
e.stopPropagation();
|
| 686 |
+
removeJob(res.jobId);
|
| 687 |
+
}}
|
| 688 |
+
className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/10 text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors"
|
| 689 |
+
>
|
| 690 |
+
<MaterialIcon name="close" className="text-xs" />
|
| 691 |
+
</button>
|
| 692 |
+
</button>
|
| 693 |
+
);
|
| 694 |
+
})}
|
| 695 |
+
</div>
|
| 696 |
+
|
| 697 |
+
{/* Active Tab Content */}
|
| 698 |
+
{activeResult && (
|
| 699 |
+
<ResultSection
|
| 700 |
+
result={activeResult.result}
|
| 701 |
+
generatedPackageId={activeResult.generatedPackageId}
|
| 702 |
+
onClear={resetAll}
|
| 703 |
+
/>
|
| 704 |
+
)}
|
| 705 |
+
</div>
|
| 706 |
+
)}
|
| 707 |
+
</div>
|
| 708 |
+
|
| 709 |
+
<PageTour
|
| 710 |
+
storageKey={GENERATE_TOUR_KEY}
|
| 711 |
+
autoDelay={600}
|
| 712 |
+
steps={generatePageSteps}
|
| 713 |
+
/>
|
| 714 |
+
<TourHelpButton storageKey={GENERATE_TOUR_KEY} />
|
| 715 |
+
</div>
|
| 716 |
+
);
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
// ── Generate page tour ──
|
| 720 |
+
const GENERATE_TOUR_KEY = "labas-page-tour-generate";
|
| 721 |
+
const generatePageSteps: Step[] = [
|
| 722 |
+
{
|
| 723 |
+
target: "[data-tour='generate-exam-type']",
|
| 724 |
+
title: "Jenis Ujian",
|
| 725 |
+
content: "Pilih jenis ujian yang ingin kamu latih. Tersedia IELTS, TOEFL, JLPT, HSK, Goethe, TOPIK (Korea), TOAFL (Arab), dan DELE (Spanyol).",
|
| 726 |
+
spotlightPadding: 8,
|
| 727 |
+
},
|
| 728 |
+
{
|
| 729 |
+
target: "[data-tour='generate-section']",
|
| 730 |
+
title: "Section",
|
| 731 |
+
content: "Pilih section yang ingin digenerate. Bisa pilih lebih dari satu. Mode Agentic dengan ≥20 soal otomatis membagi soal ke setiap section.",
|
| 732 |
+
spotlightPadding: 8,
|
| 733 |
+
},
|
| 734 |
+
{
|
| 735 |
+
target: "[data-tour='generate-count']",
|
| 736 |
+
title: "Jumlah Soal",
|
| 737 |
+
content: "Atur jumlah soal yang ingin digenerate via preset atau slider. Maksimal 40 soal per generate.",
|
| 738 |
+
spotlightPadding: 8,
|
| 739 |
+
},
|
| 740 |
+
{
|
| 741 |
+
target: "[data-tour='generate-difficulty']",
|
| 742 |
+
title: "Tingkat Kesulitan",
|
| 743 |
+
content: "Pilih tingkat kesulitan. Label menyesuaikan dengan jenis ujian yang dipilih (misal: N5-N1 untuk JLPT, Band 4.0-8.0 untuk IELTS).",
|
| 744 |
+
spotlightPadding: 8,
|
| 745 |
+
},
|
| 746 |
+
{
|
| 747 |
+
target: "[data-tour='generate-format']",
|
| 748 |
+
title: "Format Soal",
|
| 749 |
+
content: "Pilih format soal (multiple choice, true/false, dll). Format tersedia tergantung exam type yang dipilih.",
|
| 750 |
+
spotlightPadding: 8,
|
| 751 |
+
},
|
| 752 |
+
{
|
| 753 |
+
target: "[data-tour='generate-topic']",
|
| 754 |
+
title: "Topik",
|
| 755 |
+
content: "Pilih topik yang ingin difokuskan. Bisa pilih lebih dari satu topik.",
|
| 756 |
+
spotlightPadding: 8,
|
| 757 |
+
},
|
| 758 |
+
{
|
| 759 |
+
target: "[data-tour='generate-weakness']",
|
| 760 |
+
title: "Intelligent Focus",
|
| 761 |
+
content: "Atur fokus pada kelemahan kamu. AI akan menarget area yang perlu ditingkatkan berdasarkan riwayat jawaban.",
|
| 762 |
+
spotlightPadding: 8,
|
| 763 |
+
},
|
| 764 |
+
{
|
| 765 |
+
target: "[data-tour='generate-blueprint']",
|
| 766 |
+
title: "Test Blueprint & Generate",
|
| 767 |
+
content: "Ringkasan konfigurasi kamu. Pilih mode Quick (cepat) atau Agentic (multi-tahap). Klik 'Generate & Launch' untuk memulai!",
|
| 768 |
+
spotlightPadding: 8,
|
| 769 |
+
},
|
| 770 |
+
];
|
apps/web/src/components/routes/LandingPage.tsx
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
|
| 2 |
+
import { Button } from "@labas/ui/components/button";
|
| 3 |
+
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
+
import { useState } from "react";
|
| 6 |
+
import { authClient } from "@/lib/auth-client";
|
| 7 |
+
|
| 8 |
+
export const Route = createFileRoute("/landing")({
|
| 9 |
+
beforeLoad: async () => {
|
| 10 |
+
try {
|
| 11 |
+
const session = await authClient.getSession();
|
| 12 |
+
if (session?.data) {
|
| 13 |
+
throw redirect({ to: "/", replace: true });
|
| 14 |
+
}
|
| 15 |
+
} catch (e) {
|
| 16 |
+
if (e instanceof Response) throw e;
|
| 17 |
+
}
|
| 18 |
+
},
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
|
| 22 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className={`border-b-2 py-6 ${isDark ? 'border-[var(--ube-300)]/30' : 'border-[var(--oat-border)]'} last:border-b-0`}>
|
| 26 |
+
<button
|
| 27 |
+
type="button"
|
| 28 |
+
className="flex w-full items-center justify-between text-left focus:outline-none group"
|
| 29 |
+
onClick={() => setIsOpen(!isOpen)}
|
| 30 |
+
aria-expanded={isOpen}
|
| 31 |
+
>
|
| 32 |
+
<span className={`font-headline font-semibold text-2xl tracking-[-0.64px] transition-colors ${isDark ? 'text-[var(--pure-white)] group-hover:text-[var(--ube-300)]' : 'text-[var(--clay-black)] group-hover:text-[var(--matcha-700)]'}`}>
|
| 33 |
+
{question}
|
| 34 |
+
</span>
|
| 35 |
+
<div className={`w-10 h-10 rounded-full border-2 flex items-center justify-center transition-transform duration-300 ${isOpen ? "rotate-180" : ""} ${isDark ? 'border-[var(--ube-300)]/50 text-[var(--pure-white)] group-hover:bg-[var(--ube-300)]/20' : 'border-[var(--oat-border)] text-[var(--clay-black)] group-hover:bg-[var(--oat-light)]'}`}>
|
| 36 |
+
<MaterialIcon
|
| 37 |
+
name="expand_more"
|
| 38 |
+
className="text-2xl"
|
| 39 |
+
/>
|
| 40 |
+
</div>
|
| 41 |
+
</button>
|
| 42 |
+
<div
|
| 43 |
+
className={`overflow-hidden transition-all duration-300 ease-in-out ${isOpen ? "max-h-96 mt-4 opacity-100" : "max-h-0 opacity-0"}`}
|
| 44 |
+
>
|
| 45 |
+
<p className={`text-lg leading-relaxed pr-12 ${isDark ? 'text-[var(--ube-300)]' : 'text-[var(--warm-charcoal)]'}`}>
|
| 46 |
+
{answer}
|
| 47 |
+
</p>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function LandingPage() {
|
| 54 |
+
return (
|
| 55 |
+
<div className="min-h-screen bg-[var(--warm-cream)] flex flex-col font-sans selection:bg-[var(--matcha-300)] selection:text-[var(--clay-black)]">
|
| 56 |
+
{/* Navbar */}
|
| 57 |
+
<nav className="w-full px-6 py-4 md:px-12 lg:px-16 flex items-center justify-between max-w-7xl mx-auto z-50 sticky top-0 bg-[var(--warm-cream)] border-b-2 border-[var(--oat-border)]">
|
| 58 |
+
<div className="flex items-center gap-3">
|
| 59 |
+
<img src="/logo.png" alt="Labas Logo" className="h-10 w-auto object-contain" />
|
| 60 |
+
<span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
|
| 61 |
+
</div>
|
| 62 |
+
<div className="flex items-center gap-4">
|
| 63 |
+
<Link to="/login">
|
| 64 |
+
<Button variant="ghost" className="text-[var(--clay-black)] font-semibold hover:bg-[var(--oat-light)] rounded-[12px] text-lg px-6 h-12">
|
| 65 |
+
Masuk
|
| 66 |
+
</Button>
|
| 67 |
+
</Link>
|
| 68 |
+
<Link to="/login">
|
| 69 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--dark-charcoal)] rounded-[24px] h-12 px-8 font-semibold text-lg clay-hover clay-shadow">
|
| 70 |
+
Mulai Gratis
|
| 71 |
+
</Button>
|
| 72 |
+
</Link>
|
| 73 |
+
</div>
|
| 74 |
+
</nav>
|
| 75 |
+
|
| 76 |
+
<main className="flex-1 flex flex-col items-center overflow-x-hidden">
|
| 77 |
+
{/* HUGE HERO SECTION */}
|
| 78 |
+
<section className="w-full px-6 md:px-12 lg:px-16 pt-16 pb-20 md:pt-24 md:pb-32 flex flex-col xl:flex-row items-center justify-between gap-12 lg:gap-16 relative max-w-[1440px] mx-auto">
|
| 79 |
+
<div className="flex-1 text-center xl:text-left space-y-8 z-10 w-full max-w-[800px] xl:max-w-none">
|
| 80 |
+
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--matcha-300)] border-2 border-[var(--matcha-800)] text-[var(--clay-black)] uppercase-label shadow-sm mx-auto xl:mx-0 transform -rotate-2">
|
| 81 |
+
<MaterialIcon name="auto_awesome" className="text-sm" />
|
| 82 |
+
<span>Didukung oleh AI Generative</span>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<h1 className="text-[50px] md:text-[70px] lg:text-[85px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] lg:tracking-[-3.2px] leading-[1.0] lg:leading-[0.95] drop-shadow-sm">
|
| 86 |
+
Platform Latihan Bahasa Cerdas.
|
| 87 |
+
</h1>
|
| 88 |
+
|
| 89 |
+
<p className="text-xl md:text-2xl text-[var(--warm-charcoal)] max-w-2xl mx-auto xl:mx-0 leading-relaxed">
|
| 90 |
+
Persiapkan dirimu untuk ujian bahasa asing dengan latihan soal interaktif, mock test realistis, dan AI Generator super cepat.
|
| 91 |
+
</p>
|
| 92 |
+
|
| 93 |
+
<div className="flex flex-col sm:flex-row items-center gap-6 justify-center xl:justify-start pt-6">
|
| 94 |
+
<Link to="/login" className="w-full sm:w-auto">
|
| 95 |
+
<Button className="w-full sm:w-auto bg-[var(--pure-white)] text-[var(--clay-black)] rounded-[24px] h-[64px] px-10 text-xl font-semibold border-2 border-[var(--oat-border)] clay-shadow clay-hover">
|
| 96 |
+
Mulai Latihan Sekarang
|
| 97 |
+
</Button>
|
| 98 |
+
</Link>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
{/* MASSIVE HERO IMAGE WITH FLOATING ASSETS - SIDE BY SIDE ON DESKTOP */}
|
| 103 |
+
<div className="flex-1 w-full relative group mt-8 xl:mt-0 flex justify-center xl:justify-end">
|
| 104 |
+
<div className="relative w-full max-w-[650px] lg:max-w-[750px]">
|
| 105 |
+
<img
|
| 106 |
+
src="/hero_img.png"
|
| 107 |
+
alt="Labas Dashboard Preview"
|
| 108 |
+
className="w-full h-auto object-contain transform transition-transform duration-700 group-hover:scale-105 group-hover:-rotate-1 drop-shadow-[0_20px_50px_rgba(0,0,0,0.15)] relative z-10"
|
| 109 |
+
/>
|
| 110 |
+
{/* Variatif Floating Elements */}
|
| 111 |
+
<div className="absolute -top-8 -left-8 md:-top-12 md:-left-12 w-32 md:w-40 h-auto z-20 hidden sm:block">
|
| 112 |
+
<img src="/generateai.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
|
| 113 |
+
</div>
|
| 114 |
+
<div className="absolute -bottom-8 -right-8 md:-bottom-12 md:-right-12 w-40 md:w-48 h-auto z-20 hidden sm:block">
|
| 115 |
+
<img src="/mocktest.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
|
| 116 |
+
</div>
|
| 117 |
+
<div className="absolute top-[40%] -left-16 md:-left-24 w-24 md:w-32 h-auto z-0 hidden lg:block opacity-80 blur-[1px]">
|
| 118 |
+
<img src="/progress.png" alt="floating element" className="w-full h-auto drop-shadow-xl" />
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</section>
|
| 123 |
+
|
| 124 |
+
{/* Feature Cards Section */}
|
| 125 |
+
<section id="features" className="w-full bg-[var(--pure-white)] py-32 border-y-2 border-dashed border-[var(--oat-border)]">
|
| 126 |
+
<div className="max-w-7xl mx-auto px-6 md:px-12 lg:px-16">
|
| 127 |
+
<div className="text-center max-w-4xl mx-auto mb-24 space-y-6">
|
| 128 |
+
<h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] leading-tight">
|
| 129 |
+
Fitur Unggulan Labas
|
| 130 |
+
</h2>
|
| 131 |
+
<p className="text-2xl text-[var(--warm-charcoal)]">
|
| 132 |
+
Desain yang elegan, namun kokoh untuk mempercepat kesiapan Anda menghadapi ujian.
|
| 133 |
+
</p>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 137 |
+
{/* Feature 1 */}
|
| 138 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 139 |
+
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 140 |
+
<img src="/generateai.png" alt="AI Generator" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 141 |
+
</div>
|
| 142 |
+
<CardContent className="p-6">
|
| 143 |
+
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 144 |
+
AI Generator
|
| 145 |
+
</h3>
|
| 146 |
+
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 147 |
+
Hasilkan soal latihan baru menggunakan AI. Tentukan topik, level kesulitan, dan format dalam hitungan detik.
|
| 148 |
+
</p>
|
| 149 |
+
</CardContent>
|
| 150 |
+
</Card>
|
| 151 |
+
|
| 152 |
+
{/* Feature 2 */}
|
| 153 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 154 |
+
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 155 |
+
<img src="/latihansoal.png" alt="Latihan Soal" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 156 |
+
</div>
|
| 157 |
+
<CardContent className="p-6">
|
| 158 |
+
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 159 |
+
Latihan Terfokus
|
| 160 |
+
</h3>
|
| 161 |
+
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 162 |
+
Akses ribuan soal latihan dari bank soal. Buat paket latihan Anda sendiri dan fokus pada area kelemahan.
|
| 163 |
+
</p>
|
| 164 |
+
</CardContent>
|
| 165 |
+
</Card>
|
| 166 |
+
|
| 167 |
+
{/* Feature 3 */}
|
| 168 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 169 |
+
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 170 |
+
<img src="/mocktest.png" alt="Mock Test" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 171 |
+
</div>
|
| 172 |
+
<CardContent className="p-6">
|
| 173 |
+
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 174 |
+
Simulasi Ujian
|
| 175 |
+
</h3>
|
| 176 |
+
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 177 |
+
Simulasikan suasana ujian sesungguhnya dengan batas waktu, antarmuka imersif, dan penilaian instan.
|
| 178 |
+
</p>
|
| 179 |
+
</CardContent>
|
| 180 |
+
</Card>
|
| 181 |
+
|
| 182 |
+
{/* Feature 4 */}
|
| 183 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 184 |
+
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 185 |
+
<img src="/progress.png" alt="Progress Tracking" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 186 |
+
</div>
|
| 187 |
+
<CardContent className="p-6">
|
| 188 |
+
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 189 |
+
Analitik Progres
|
| 190 |
+
</h3>
|
| 191 |
+
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 192 |
+
Lacak perkembangan nilai Anda dari waktu ke waktu. Analisis mendalam untuk setiap bagian tes bahasa.
|
| 193 |
+
</p>
|
| 194 |
+
</CardContent>
|
| 195 |
+
</Card>
|
| 196 |
+
|
| 197 |
+
{/* Feature 5 */}
|
| 198 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 199 |
+
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 200 |
+
<img src="/vocabulary.png" alt="Vocabulary" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 201 |
+
</div>
|
| 202 |
+
<CardContent className="p-6">
|
| 203 |
+
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 204 |
+
Kosakata
|
| 205 |
+
</h3>
|
| 206 |
+
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 207 |
+
Perkaya kosakata Anda dengan metode cerdas dan pengulangan berkala yang dioptimalkan.
|
| 208 |
+
</p>
|
| 209 |
+
</CardContent>
|
| 210 |
+
</Card>
|
| 211 |
+
|
| 212 |
+
{/* Feature 6 */}
|
| 213 |
+
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 214 |
+
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 215 |
+
<img src="/diskusi.png" alt="Diskusi" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 216 |
+
</div>
|
| 217 |
+
<CardContent className="p-6">
|
| 218 |
+
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 219 |
+
Forum Diskusi
|
| 220 |
+
</h3>
|
| 221 |
+
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 222 |
+
Diskusikan soal-soal sulit dengan komunitas pembelajar lainnya dan dapatkan penjelasan ahli.
|
| 223 |
+
</p>
|
| 224 |
+
</CardContent>
|
| 225 |
+
</Card>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
</section>
|
| 229 |
+
|
| 230 |
+
{/* SWATCH ROOM 1: Ube FAQ Section */}
|
| 231 |
+
<section className="w-full bg-[var(--ube-800)] py-32 rounded-t-[40px] mt-[-40px] z-10 relative shadow-[0_-10px_40px_rgba(0,0,0,0.1)] border-t-2 border-[var(--oat-border)]/20">
|
| 232 |
+
<div className="max-w-4xl mx-auto px-6 md:px-12 lg:px-16">
|
| 233 |
+
<div className="text-center mb-16">
|
| 234 |
+
<h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--pure-white)] tracking-[-2.4px] mb-6 leading-tight drop-shadow-sm">
|
| 235 |
+
Pertanyaan Umum
|
| 236 |
+
</h2>
|
| 237 |
+
<p className="text-[var(--ube-300)] text-2xl max-w-2xl mx-auto">
|
| 238 |
+
Temukan jawaban cepat untuk pertanyaan seputar Labas.
|
| 239 |
+
</p>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<div className="bg-[var(--ube-900)]/40 backdrop-blur-sm border-2 border-[var(--ube-300)]/30 rounded-[32px] p-8 md:p-12 shadow-2xl">
|
| 243 |
+
<FaqItem
|
| 244 |
+
isDark
|
| 245 |
+
question="Apa itu Labas?"
|
| 246 |
+
answer="Labas adalah platform latihan ujian bahasa berbasis AI yang dirancang untuk membantu Anda berlatih dan menguasai bahasa asing melalui simulasi, bank soal interaktif, dan analitik performa."
|
| 247 |
+
/>
|
| 248 |
+
<FaqItem
|
| 249 |
+
isDark
|
| 250 |
+
question="Bagaimana cara kerja AI Generator?"
|
| 251 |
+
answer="Fitur AI Generator memungkinkan Anda membuat paket soal baru berdasarkan konteks atau topik tertentu. Anda cukup memasukkan teks acuan, dan AI Agent kami akan memproduksi soal secara otomatis."
|
| 252 |
+
/>
|
| 253 |
+
<FaqItem
|
| 254 |
+
isDark
|
| 255 |
+
question="Apakah Labas sepenuhnya gratis?"
|
| 256 |
+
answer="Platform Labas dapat digunakan secara gratis untuk fitur dasar. Untuk fitur generasi soal berbasis AI, kami menggunakan model Bring-Your-Own-Key (BYOK). Anda cukup memasukkan API Key OpenAI Anda."
|
| 257 |
+
/>
|
| 258 |
+
<FaqItem
|
| 259 |
+
isDark
|
| 260 |
+
question="Bahasa apa saja yang didukung oleh Labas?"
|
| 261 |
+
answer="Saat ini Labas mendukung latihan untuk berbagai ujian profisiensi bahasa populer seperti Bahasa Inggris (TOEFL, IELTS, TOEIC), Jepang (JLPT), Korea (TOPIK), dan banyak lagi."
|
| 262 |
+
/>
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
</section>
|
| 266 |
+
|
| 267 |
+
{/* SWATCH ROOM 2: Matcha CTA Section */}
|
| 268 |
+
<section className="w-full bg-[var(--matcha-800)] py-40 px-6 rounded-t-[40px] mt-[-40px] z-20 relative shadow-[0_-10px_40px_rgba(0,0,0,0.2)] border-t-2 border-[var(--matcha-600)]">
|
| 269 |
+
<div className="max-w-4xl mx-auto text-center space-y-12">
|
| 270 |
+
<h2 className="text-[60px] md:text-[80px] font-headline font-semibold text-[var(--pure-white)] tracking-[-3.2px] leading-[0.95] drop-shadow-md">
|
| 271 |
+
Siap Meningkatkan Skor Anda?
|
| 272 |
+
</h2>
|
| 273 |
+
<p className="text-2xl text-[var(--matcha-300)] max-w-2xl mx-auto">
|
| 274 |
+
Bergabung sekarang dan rasakan perbedaan belajar dengan teknologi yang berpusat pada perkembangan Anda.
|
| 275 |
+
</p>
|
| 276 |
+
<div className="pt-8">
|
| 277 |
+
<Link to="/login" className="inline-block">
|
| 278 |
+
<Button className="bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] rounded-[24px] h-[80px] px-14 text-2xl font-bold clay-shadow clay-hover">
|
| 279 |
+
Daftar Sekarang - Gratis
|
| 280 |
+
</Button>
|
| 281 |
+
</Link>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</section>
|
| 285 |
+
</main>
|
| 286 |
+
|
| 287 |
+
{/* Footer */}
|
| 288 |
+
<footer className="w-full bg-[var(--pure-white)] py-12 border-t-2 border-[var(--oat-border)] text-center relative z-30">
|
| 289 |
+
<p className="text-[var(--warm-charcoal)] font-semibold text-lg">
|
| 290 |
+
© {new Date().getFullYear()} Labas. Didesain dengan penuh kehangatan.
|
| 291 |
+
</p>
|
| 292 |
+
</footer>
|
| 293 |
+
</div>
|
| 294 |
+
);
|
| 295 |
+
}
|
apps/web/src/components/routes/PackagesPage.tsx
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback } from "react";
|
| 2 |
+
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
+
import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
|
| 4 |
+
import { z } from "zod";
|
| 5 |
+
import { authClient } from "@/lib/auth-client";
|
| 6 |
+
import { trpc } from "@/utils/trpc";
|
| 7 |
+
import { Input } from "@labas/ui/components/input";
|
| 8 |
+
import { Button } from "@labas/ui/components/button";
|
| 9 |
+
import { Card, CardContent } from "@labas/ui/components/card";
|
| 10 |
+
import {
|
| 11 |
+
Select,
|
| 12 |
+
SelectContent,
|
| 13 |
+
SelectGroup,
|
| 14 |
+
SelectItem,
|
| 15 |
+
SelectTrigger,
|
| 16 |
+
SelectValue,
|
| 17 |
+
} from "@labas/ui/components/select";
|
| 18 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 19 |
+
import { GettingStartedCard } from "@/components/GettingStartedCard";
|
| 20 |
+
import { CalloutCard } from "@/components/bank/CalloutCard";
|
| 21 |
+
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 22 |
+
import type { Step } from "react-joyride";
|
| 23 |
+
import { toast } from "sonner";
|
| 24 |
+
import { getErrorMessage } from "@/lib/error-utils";
|
| 25 |
+
import { EXAM_TYPES } from "@/lib/exam-constants";
|
| 26 |
+
|
| 27 |
+
export const Route = createFileRoute("/packages")({
|
| 28 |
+
validateSearch: z.object({
|
| 29 |
+
tab: z.enum(["all", "mine"]).optional(),
|
| 30 |
+
search: z.string().optional(),
|
| 31 |
+
examType: z.string().optional(),
|
| 32 |
+
page: z.coerce.number().optional(),
|
| 33 |
+
visibility: z.enum(["all", "private", "public"]).optional(),
|
| 34 |
+
}).parse,
|
| 35 |
+
beforeLoad: async () => {
|
| 36 |
+
const session = await authClient.getSession();
|
| 37 |
+
if (!session.data) {
|
| 38 |
+
redirect({ to: "/login", throw: true });
|
| 39 |
+
}
|
| 40 |
+
return { session };
|
| 41 |
+
},
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
type Tab = "all" | "mine";
|
| 45 |
+
|
| 46 |
+
export function PackagesComponent() {
|
| 47 |
+
const routerNavigate = useNavigate();
|
| 48 |
+
const search = Route.useSearch();
|
| 49 |
+
const navigate = Route.useNavigate();
|
| 50 |
+
const { data: session } = authClient.useSession();
|
| 51 |
+
const userId = session?.user.id;
|
| 52 |
+
|
| 53 |
+
const tab = search.tab ?? "all";
|
| 54 |
+
const searchText = search.search ?? "";
|
| 55 |
+
const examType = search.examType ?? "";
|
| 56 |
+
const page = search.page ?? 1;
|
| 57 |
+
const visibilityFilter = search.visibility ?? "all";
|
| 58 |
+
const limit = 12;
|
| 59 |
+
|
| 60 |
+
const allQuery = useQuery(
|
| 61 |
+
trpc.package.list.queryOptions(
|
| 62 |
+
{
|
| 63 |
+
isPublic: true,
|
| 64 |
+
examTypeId: examType || undefined,
|
| 65 |
+
search: searchText || undefined,
|
| 66 |
+
limit,
|
| 67 |
+
offset: (page - 1) * limit,
|
| 68 |
+
},
|
| 69 |
+
{ enabled: tab === "all" },
|
| 70 |
+
),
|
| 71 |
+
);
|
| 72 |
+
|
| 73 |
+
const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
|
| 74 |
+
? { isPublic: visibilityFilter === "public" }
|
| 75 |
+
: {};
|
| 76 |
+
|
| 77 |
+
const mineQuery = useQuery(
|
| 78 |
+
trpc.package.myPackages.queryOptions(
|
| 79 |
+
{
|
| 80 |
+
search: searchText || undefined,
|
| 81 |
+
examTypeId: examType || undefined,
|
| 82 |
+
limit,
|
| 83 |
+
offset: (page - 1) * limit,
|
| 84 |
+
...visibilityFilterParam,
|
| 85 |
+
},
|
| 86 |
+
{ enabled: tab === "mine" },
|
| 87 |
+
),
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
const query = tab === "all" ? allQuery : mineQuery;
|
| 91 |
+
const packages = query.data?.packages ?? [];
|
| 92 |
+
const total = query.data?.total ?? 0;
|
| 93 |
+
const totalPages = Math.ceil(total / limit);
|
| 94 |
+
|
| 95 |
+
const updateMutation = useMutation(
|
| 96 |
+
trpc.package.update.mutationOptions({
|
| 97 |
+
onSuccess: () => {
|
| 98 |
+
query.refetch();
|
| 99 |
+
},
|
| 100 |
+
}),
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
const togglePublic = (pkgId: string, current: boolean) => {
|
| 104 |
+
updateMutation.mutate({ id: pkgId, isPublic: !current });
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
const bulkPublish = useMutation(
|
| 108 |
+
trpc.package.bulkPublish.mutationOptions({
|
| 109 |
+
onSuccess: (data) => {
|
| 110 |
+
query.refetch();
|
| 111 |
+
setBulkMode(false);
|
| 112 |
+
setSelectedIds(new Set());
|
| 113 |
+
if (data.skipped > 0) {
|
| 114 |
+
toast.success(
|
| 115 |
+
`${data.updated} paket dipublikasikan, ${data.skipped} dilewati`,
|
| 116 |
+
{ description: "Beberapa paket bukan milikmu atau sudah tidak tersedia." },
|
| 117 |
+
);
|
| 118 |
+
} else {
|
| 119 |
+
toast.success(`${data.updated} paket berhasil dipublikasikan`);
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
onError: (err: unknown) => {
|
| 123 |
+
toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang paket.", { description: getErrorMessage(err) });
|
| 124 |
+
},
|
| 125 |
+
}),
|
| 126 |
+
);
|
| 127 |
+
|
| 128 |
+
// ── Bulk select ──
|
| 129 |
+
const [bulkMode, setBulkMode] = useState(false);
|
| 130 |
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 131 |
+
|
| 132 |
+
const toggleSelect = (id: string) => {
|
| 133 |
+
setSelectedIds((prev) => {
|
| 134 |
+
const next = new Set(prev);
|
| 135 |
+
if (next.has(id)) next.delete(id);
|
| 136 |
+
else next.add(id);
|
| 137 |
+
return next;
|
| 138 |
+
});
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const clearSelection = () => setSelectedIds(new Set());
|
| 142 |
+
const selectAll = () => setSelectedIds(new Set(packages.map((p) => p.id)));
|
| 143 |
+
|
| 144 |
+
useEffect(() => {
|
| 145 |
+
setBulkMode(false);
|
| 146 |
+
setSelectedIds(new Set());
|
| 147 |
+
}, [tab, searchText, examType]);
|
| 148 |
+
|
| 149 |
+
const setTab = (newTab: Tab) => {
|
| 150 |
+
navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
// ── Private callout state ──
|
| 154 |
+
const [calloutDismissed, setCalloutDismissed] = useState(
|
| 155 |
+
typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
|
| 156 |
+
);
|
| 157 |
+
const privatePackages = packages.filter(
|
| 158 |
+
(p) => !p.isPublic && p.creatorUserId === userId,
|
| 159 |
+
);
|
| 160 |
+
|
| 161 |
+
const handleDismissCallout = () => {
|
| 162 |
+
localStorage.setItem("labas-packages-private-callout-dismissed", "true");
|
| 163 |
+
setCalloutDismissed(true);
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
const handlePublishAllPrivate = () => {
|
| 167 |
+
const ids = privatePackages.map((p) => p.id);
|
| 168 |
+
if (ids.length > 0) bulkPublish.mutate({ ids });
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
const setSearch = (value: string) => {
|
| 172 |
+
navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const setExamType = (value: string) => {
|
| 176 |
+
navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
|
| 177 |
+
};
|
| 178 |
+
|
| 179 |
+
const setVisibility = (value: "all" | "private" | "public") => {
|
| 180 |
+
navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value, page: 1 }) });
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const setPage = (newPage: number) => {
|
| 184 |
+
navigate({ search: (prev) => ({ ...prev, page: newPage }) });
|
| 185 |
+
};
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
|
| 189 |
+
<section className="mb-8">
|
| 190 |
+
<div data-tour="packages-header" className="flex items-center justify-between">
|
| 191 |
+
<div>
|
| 192 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 193 |
+
Paket Soal
|
| 194 |
+
</h1>
|
| 195 |
+
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 196 |
+
Kumpulan paket latihan dari komunitas.
|
| 197 |
+
</p>
|
| 198 |
+
</div>
|
| 199 |
+
<Link to="/bank">
|
| 200 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] h-11">
|
| 201 |
+
<MaterialIcon name="add" />
|
| 202 |
+
<span className="ml-2 hidden sm:inline">Buat Paket</span>
|
| 203 |
+
</Button>
|
| 204 |
+
</Link>
|
| 205 |
+
</div>
|
| 206 |
+
</section>
|
| 207 |
+
|
| 208 |
+
{/* Getting Started Guide */}
|
| 209 |
+
<GettingStartedCard />
|
| 210 |
+
|
| 211 |
+
{/* Tabs */}
|
| 212 |
+
<div className="flex gap-2 mb-6">
|
| 213 |
+
<button
|
| 214 |
+
onClick={() => setTab("all")}
|
| 215 |
+
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 216 |
+
tab === "all"
|
| 217 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 218 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 219 |
+
}`}
|
| 220 |
+
>
|
| 221 |
+
Semua Paket
|
| 222 |
+
</button>
|
| 223 |
+
<button
|
| 224 |
+
onClick={() => setTab("mine")}
|
| 225 |
+
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 226 |
+
tab === "mine"
|
| 227 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 228 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 229 |
+
}`}
|
| 230 |
+
>
|
| 231 |
+
Paket Saya
|
| 232 |
+
</button>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
{/* Filters */}
|
| 236 |
+
<div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
|
| 237 |
+
<div className="relative flex-1 max-w-md">
|
| 238 |
+
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 239 |
+
<Input
|
| 240 |
+
placeholder="Cari paket..."
|
| 241 |
+
value={searchText}
|
| 242 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 243 |
+
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 244 |
+
/>
|
| 245 |
+
</div>
|
| 246 |
+
<Select
|
| 247 |
+
value={examType}
|
| 248 |
+
onValueChange={(v: string | null) => setExamType(v ?? "")}
|
| 249 |
+
>
|
| 250 |
+
<SelectTrigger className="w-36">
|
| 251 |
+
<SelectValue placeholder="Semua Ujian" />
|
| 252 |
+
</SelectTrigger>
|
| 253 |
+
<SelectContent>
|
| 254 |
+
<SelectGroup>
|
| 255 |
+
<SelectItem value="">Semua Ujian</SelectItem>
|
| 256 |
+
{EXAM_TYPES.map((t) => (
|
| 257 |
+
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
| 258 |
+
))}
|
| 259 |
+
</SelectGroup>
|
| 260 |
+
</SelectContent>
|
| 261 |
+
</Select>
|
| 262 |
+
{tab === "mine" && (
|
| 263 |
+
<div className="flex gap-2">
|
| 264 |
+
<VisChip active={visibilityFilter === "all"} onClick={() => setVisibility("all")}>
|
| 265 |
+
<MaterialIcon name="visibility" className="text-xs" />
|
| 266 |
+
Semua
|
| 267 |
+
</VisChip>
|
| 268 |
+
<VisChip active={visibilityFilter === "private"} onClick={() => setVisibility("private")}>
|
| 269 |
+
<MaterialIcon name="lock" className="text-xs" />
|
| 270 |
+
Privat
|
| 271 |
+
</VisChip>
|
| 272 |
+
<VisChip active={visibilityFilter === "public"} onClick={() => setVisibility("public")}>
|
| 273 |
+
<MaterialIcon name="public" className="text-xs" />
|
| 274 |
+
Publik
|
| 275 |
+
</VisChip>
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
</div>
|
| 279 |
+
|
| 280 |
+
{/* Bulk toolbar */}
|
| 281 |
+
{tab === "mine" && (
|
| 282 |
+
<div className="flex items-center justify-between mb-4 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)] border-2 border-[var(--oat-border)]">
|
| 283 |
+
{bulkMode ? (
|
| 284 |
+
<>
|
| 285 |
+
<div className="flex items-center gap-3">
|
| 286 |
+
<button
|
| 287 |
+
onClick={clearSelection}
|
| 288 |
+
className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center gap-1"
|
| 289 |
+
>
|
| 290 |
+
<MaterialIcon name="close" className="text-xs" />
|
| 291 |
+
Batalkan ({selectedIds.size})
|
| 292 |
+
</button>
|
| 293 |
+
<button
|
| 294 |
+
onClick={selectAll}
|
| 295 |
+
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
|
| 296 |
+
>
|
| 297 |
+
<MaterialIcon name="select_all" className="text-xs" />
|
| 298 |
+
Pilih Semua
|
| 299 |
+
</button>
|
| 300 |
+
</div>
|
| 301 |
+
<div className="flex items-center gap-2">
|
| 302 |
+
<Button
|
| 303 |
+
size="lg"
|
| 304 |
+
disabled={selectedIds.size === 0 || bulkPublish.isPending}
|
| 305 |
+
onClick={() => bulkPublish.mutate({ ids: Array.from(selectedIds) })}
|
| 306 |
+
className="rounded-[var(--radius-md)] bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)]"
|
| 307 |
+
>
|
| 308 |
+
<MaterialIcon name="public" className="text-xs mr-1" />
|
| 309 |
+
{bulkPublish.isPending ? "Mempublikasikan..." : `Jadikan Publik (${selectedIds.size})`}
|
| 310 |
+
</Button>
|
| 311 |
+
<button
|
| 312 |
+
onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
|
| 313 |
+
className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
|
| 314 |
+
>
|
| 315 |
+
Selesai
|
| 316 |
+
</button>
|
| 317 |
+
</div>
|
| 318 |
+
</>
|
| 319 |
+
) : (
|
| 320 |
+
<>
|
| 321 |
+
<span className="text-sm text-[var(--warm-charcoal)]">{packages.length} paket</span>
|
| 322 |
+
<button
|
| 323 |
+
onClick={() => setBulkMode(true)}
|
| 324 |
+
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
|
| 325 |
+
>
|
| 326 |
+
<MaterialIcon name="select_all" className="text-sm" />
|
| 327 |
+
Pilih Banyak
|
| 328 |
+
</button>
|
| 329 |
+
</>
|
| 330 |
+
)}
|
| 331 |
+
</div>
|
| 332 |
+
)}
|
| 333 |
+
|
| 334 |
+
{/* Private package callout */}
|
| 335 |
+
{tab === "mine" && privatePackages.length > 0 && !calloutDismissed && (
|
| 336 |
+
<div className="mb-6">
|
| 337 |
+
<CalloutCard
|
| 338 |
+
privateCount={privatePackages.length}
|
| 339 |
+
onPublishAll={handlePublishAllPrivate}
|
| 340 |
+
onDismiss={handleDismissCallout}
|
| 341 |
+
/>
|
| 342 |
+
</div>
|
| 343 |
+
)}
|
| 344 |
+
|
| 345 |
+
{/* Results */}
|
| 346 |
+
{query.isLoading ? (
|
| 347 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 348 |
+
{Array.from({ length: 6 }).map((_, i) => (
|
| 349 |
+
<Card key={i} className="h-48 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 350 |
+
))}
|
| 351 |
+
</div>
|
| 352 |
+
) : packages.length === 0 ? (
|
| 353 |
+
<div className="text-center py-16">
|
| 354 |
+
<MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 355 |
+
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
|
| 356 |
+
<p className="text-sm text-[var(--warm-silver)] mt-1 mb-6">
|
| 357 |
+
{tab === "mine"
|
| 358 |
+
? "Belum ada paket yang Anda buat. Buat paket dari Bank Soal."
|
| 359 |
+
: "Belum ada paket publik. Buat paket soal pertama Anda"}
|
| 360 |
+
</p>
|
| 361 |
+
<div className="flex items-center justify-center gap-3">
|
| 362 |
+
<Link to="/generate">
|
| 363 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
|
| 364 |
+
<MaterialIcon name="auto_awesome" className="mr-2" />
|
| 365 |
+
Generate Soal
|
| 366 |
+
</Button>
|
| 367 |
+
</Link>
|
| 368 |
+
<Link to="/bank">
|
| 369 |
+
<Button variant="outline" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]">
|
| 370 |
+
<MaterialIcon name="add" className="mr-2" />
|
| 371 |
+
Buat Paket
|
| 372 |
+
</Button>
|
| 373 |
+
</Link>
|
| 374 |
+
</div>
|
| 375 |
+
</div>
|
| 376 |
+
) : (
|
| 377 |
+
<>
|
| 378 |
+
<div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 379 |
+
{packages.map((pkg) => {
|
| 380 |
+
const isOwner = pkg.creatorUserId === userId;
|
| 381 |
+
const isSelected = selectedIds.has(pkg.id);
|
| 382 |
+
return (
|
| 383 |
+
<Card
|
| 384 |
+
key={pkg.id}
|
| 385 |
+
className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
|
| 386 |
+
bulkMode && isSelected
|
| 387 |
+
? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
|
| 388 |
+
: isOwner && !pkg.isPublic && !bulkMode
|
| 389 |
+
? "border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4"
|
| 390 |
+
: "border-[var(--oat-border)]"
|
| 391 |
+
}`}
|
| 392 |
+
>
|
| 393 |
+
<CardContent className="p-5 flex flex-col h-full">
|
| 394 |
+
<div
|
| 395 |
+
className="block flex-1 cursor-pointer"
|
| 396 |
+
onClick={bulkMode ? () => toggleSelect(pkg.id) : undefined}
|
| 397 |
+
>
|
| 398 |
+
<Link
|
| 399 |
+
to="/package/$id"
|
| 400 |
+
params={{ id: pkg.id }}
|
| 401 |
+
className={bulkMode ? "pointer-events-none" : ""}
|
| 402 |
+
>
|
| 403 |
+
<div className="flex items-start justify-between mb-3">
|
| 404 |
+
<div className="flex gap-2 flex-wrap">
|
| 405 |
+
{bulkMode && (
|
| 406 |
+
<span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 407 |
+
isSelected
|
| 408 |
+
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
|
| 409 |
+
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 410 |
+
}`}>
|
| 411 |
+
<MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
|
| 412 |
+
{isSelected ? "Terpilih" : "Pilih"}
|
| 413 |
+
</span>
|
| 414 |
+
)}
|
| 415 |
+
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold leading-none whitespace-nowrap">
|
| 416 |
+
{pkg.examTypeName}
|
| 417 |
+
</span>
|
| 418 |
+
{isOwner && !bulkMode && (
|
| 419 |
+
<span
|
| 420 |
+
className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 421 |
+
pkg.isPublic
|
| 422 |
+
? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
|
| 423 |
+
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
|
| 424 |
+
}`}
|
| 425 |
+
>
|
| 426 |
+
{!pkg.isPublic && <MaterialIcon name="lock" className="text-[10px]" />}
|
| 427 |
+
{pkg.isPublic ? "Publik" : "Privat"}
|
| 428 |
+
</span>
|
| 429 |
+
)}
|
| 430 |
+
</div>
|
| 431 |
+
{pkg.avgRating && (
|
| 432 |
+
<div className="flex items-center gap-1 text-[var(--lemon-700)]">
|
| 433 |
+
<MaterialIcon name="star" className="text-sm" />
|
| 434 |
+
<span className="text-xs font-bold">{pkg.avgRating}</span>
|
| 435 |
+
</div>
|
| 436 |
+
)}
|
| 437 |
+
</div>
|
| 438 |
+
|
| 439 |
+
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
|
| 440 |
+
{pkg.title}
|
| 441 |
+
</h3>
|
| 442 |
+
|
| 443 |
+
{pkg.description && (
|
| 444 |
+
<p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
|
| 445 |
+
{pkg.description}
|
| 446 |
+
</p>
|
| 447 |
+
)}
|
| 448 |
+
</Link>
|
| 449 |
+
|
| 450 |
+
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 451 |
+
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
| 452 |
+
<span className="flex items-center gap-1">
|
| 453 |
+
<MaterialIcon name="quiz" className="text-xs" />
|
| 454 |
+
{pkg.totalQuestions}
|
| 455 |
+
</span>
|
| 456 |
+
<span className="flex items-center gap-1">
|
| 457 |
+
<MaterialIcon name="folder" className="text-xs" />
|
| 458 |
+
{pkg.totalSections}
|
| 459 |
+
</span>
|
| 460 |
+
{pkg.estimatedDurationMin && (
|
| 461 |
+
<span className="flex items-center gap-1">
|
| 462 |
+
<MaterialIcon name="timer" className="text-xs" />
|
| 463 |
+
{pkg.estimatedDurationMin}m
|
| 464 |
+
</span>
|
| 465 |
+
)}
|
| 466 |
+
</div>
|
| 467 |
+
<span className="text-xs text-[var(--warm-silver)]">
|
| 468 |
+
{pkg.usageCount}x digunakan
|
| 469 |
+
</span>
|
| 470 |
+
</div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
{/* Owner actions */}
|
| 474 |
+
{isOwner && !bulkMode && (
|
| 475 |
+
<div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
|
| 476 |
+
<button
|
| 477 |
+
onClick={() => togglePublic(pkg.id, pkg.isPublic)}
|
| 478 |
+
disabled={updateMutation.isPending}
|
| 479 |
+
title={pkg.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
|
| 480 |
+
className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors flex items-center gap-1 cursor-pointer ${
|
| 481 |
+
pkg.isPublic
|
| 482 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 483 |
+
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
|
| 484 |
+
}`}
|
| 485 |
+
>
|
| 486 |
+
{!pkg.isPublic && <MaterialIcon name="lock" className="text-xs" />}
|
| 487 |
+
{pkg.isPublic ? "Publik" : "Privat"}
|
| 488 |
+
</button>
|
| 489 |
+
{pkg.isPublic && (
|
| 490 |
+
<button
|
| 491 |
+
onClick={() => {
|
| 492 |
+
const url = `${window.location.origin}/package/${pkg.id}`;
|
| 493 |
+
navigator.clipboard.writeText(url);
|
| 494 |
+
toast.success("Link paket disalin!");
|
| 495 |
+
}}
|
| 496 |
+
className="text-xs text-[var(--matcha-600)] hover:bg-[var(--matcha-300)]/20 px-3 py-1.5 rounded-full transition-colors flex items-center gap-1"
|
| 497 |
+
>
|
| 498 |
+
<MaterialIcon name="share" className="text-xs" />
|
| 499 |
+
Bagikan
|
| 500 |
+
</button>
|
| 501 |
+
)}
|
| 502 |
+
</div>
|
| 503 |
+
)}
|
| 504 |
+
|
| 505 |
+
{!bulkMode && (
|
| 506 |
+
<div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
|
| 507 |
+
<Button
|
| 508 |
+
className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
|
| 509 |
+
onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
|
| 510 |
+
size="xl"
|
| 511 |
+
>
|
| 512 |
+
<MaterialIcon name="play_arrow" className="mr-2" />
|
| 513 |
+
Mulai Latihan
|
| 514 |
+
</Button>
|
| 515 |
+
</div>
|
| 516 |
+
)}
|
| 517 |
+
</CardContent>
|
| 518 |
+
</Card>
|
| 519 |
+
);
|
| 520 |
+
})}
|
| 521 |
+
</div>
|
| 522 |
+
|
| 523 |
+
{totalPages > 1 && (
|
| 524 |
+
<div className="flex items-center justify-center gap-2 mt-10">
|
| 525 |
+
<Button
|
| 526 |
+
variant="outline"
|
| 527 |
+
onClick={() => setPage(Math.max(1, page - 1))}
|
| 528 |
+
disabled={page <= 1}
|
| 529 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 530 |
+
>
|
| 531 |
+
<MaterialIcon name="chevron_left" />
|
| 532 |
+
</Button>
|
| 533 |
+
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 534 |
+
Halaman {page} dari {totalPages}
|
| 535 |
+
</span>
|
| 536 |
+
<Button
|
| 537 |
+
variant="outline"
|
| 538 |
+
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
| 539 |
+
disabled={page >= totalPages}
|
| 540 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 541 |
+
>
|
| 542 |
+
<MaterialIcon name="chevron_right" />
|
| 543 |
+
</Button>
|
| 544 |
+
</div>
|
| 545 |
+
)}
|
| 546 |
+
</>
|
| 547 |
+
)}
|
| 548 |
+
|
| 549 |
+
<PageTour storageKey={PACKAGES_TOUR_KEY} autoDelay={600} steps={packagesPageSteps} />
|
| 550 |
+
<TourHelpButton storageKey={PACKAGES_TOUR_KEY} />
|
| 551 |
+
</div>
|
| 552 |
+
);
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
// ── Packages page tour ──
|
| 556 |
+
const PACKAGES_TOUR_KEY = "labas-page-tour-packages";
|
| 557 |
+
const packagesPageSteps: Step[] = [
|
| 558 |
+
{
|
| 559 |
+
target: "[data-tour='packages-header']",
|
| 560 |
+
title: "Paket Soal",
|
| 561 |
+
content: "Temukan paket soal dari komunitas atau lihat paket buatan sendiri. Klik 'Buat Paket' untuk membuat paket baru dari Bank Soal.",
|
| 562 |
+
spotlightPadding: 8,
|
| 563 |
+
},
|
| 564 |
+
{
|
| 565 |
+
target: "[data-tour='packages-filters']",
|
| 566 |
+
title: "Filter & Pencarian",
|
| 567 |
+
content: "Cari paket berdasarkan nama atau filter berdasarkan jenis ujian (IELTS, TOEFL, dll). Bisa juga switch antara 'Semua Paket' dan 'Paket Saya'.",
|
| 568 |
+
spotlightPadding: 8,
|
| 569 |
+
},
|
| 570 |
+
{
|
| 571 |
+
target: "[data-tour='packages-list']",
|
| 572 |
+
title: "Mulai Latihan",
|
| 573 |
+
content: "Klik kartu paket untuk lihat detail, atau langsung klik 'Mulai Latihan' untuk mengerjakan soal. Pantau skor dan progres kamu!",
|
| 574 |
+
spotlightPadding: 8,
|
| 575 |
+
},
|
| 576 |
+
];
|
| 577 |
+
|
| 578 |
+
function VisChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 579 |
+
return (
|
| 580 |
+
<button
|
| 581 |
+
onClick={onClick}
|
| 582 |
+
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
|
| 583 |
+
active
|
| 584 |
+
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 585 |
+
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 586 |
+
}`}
|
| 587 |
+
>
|
| 588 |
+
{children}
|
| 589 |
+
</button>
|
| 590 |
+
);
|
| 591 |
+
}
|
apps/web/src/components/routes/SettingsPage.tsx
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, startTransition } from "react";
|
| 2 |
+
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
+
import { useQuery } from "@tanstack/react-query";
|
| 4 |
+
import { authClient } from "@/lib/auth-client";
|
| 5 |
+
import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
|
| 6 |
+
import { trpc } from "@/utils/trpc";
|
| 7 |
+
import {
|
| 8 |
+
Tabs,
|
| 9 |
+
TabsList,
|
| 10 |
+
TabsTrigger,
|
| 11 |
+
TabsContent,
|
| 12 |
+
} from "@labas/ui/components/tabs";
|
| 13 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 14 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 15 |
+
import { TokenUsageChart } from "@/components/settings/TokenUsageChart";
|
| 16 |
+
import { ApiKeyList } from "@/components/settings/ApiKeyList";
|
| 17 |
+
import { ApiKeyForm } from "@/components/settings/ApiKeyForm";
|
| 18 |
+
import { TipsCard } from "@/components/settings/TipsCard";
|
| 19 |
+
import { SecurityInfo } from "@/components/settings/SecurityInfo";
|
| 20 |
+
import { AccountSettings } from "@/components/settings/AccountSettings";
|
| 21 |
+
import { z } from "zod";
|
| 22 |
+
|
| 23 |
+
export const Route = createFileRoute("/settings")({
|
| 24 |
+
validateSearch: z.object({
|
| 25 |
+
tab: z.enum(["api-keys", "token-usage", "security", "account"]).optional(),
|
| 26 |
+
}).parse,
|
| 27 |
+
beforeLoad: async () => {
|
| 28 |
+
const session = await authClient.getSession();
|
| 29 |
+
if (!session.data) {
|
| 30 |
+
redirect({ to: "/login", throw: true });
|
| 31 |
+
}
|
| 32 |
+
return { session };
|
| 33 |
+
},
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
const PROVIDERS = [
|
| 37 |
+
{ value: "openai", label: "OpenAI" },
|
| 38 |
+
{ value: "anthropic", label: "Anthropic" },
|
| 39 |
+
{ value: "google", label: "Google" },
|
| 40 |
+
{ value: "openrouter", label: "OpenRouter" },
|
| 41 |
+
{ value: "groq", label: "Groq" },
|
| 42 |
+
{ value: "custom", label: "Custom" },
|
| 43 |
+
];
|
| 44 |
+
|
| 45 |
+
function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
|
| 46 |
+
return {
|
| 47 |
+
name: "",
|
| 48 |
+
provider: "openai",
|
| 49 |
+
baseUrl: "https://api.openai.com/v1",
|
| 50 |
+
modelName: "gpt-4o-mini",
|
| 51 |
+
maxTokens: 16384,
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
type Tab = "api-keys" | "token-usage" | "security" | "account";
|
| 56 |
+
|
| 57 |
+
function TokenUsageSection() {
|
| 58 |
+
const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
|
| 59 |
+
|
| 60 |
+
const totalTokens = data?.totalTokens ?? 0;
|
| 61 |
+
const jobs = data?.jobs ?? [];
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<div className="space-y-6">
|
| 65 |
+
<TokenUsageChart />
|
| 66 |
+
|
| 67 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 68 |
+
<CardHeader>
|
| 69 |
+
<div className="flex items-center gap-3">
|
| 70 |
+
<MaterialIcon name="toll" className="text-xl" />
|
| 71 |
+
<div>
|
| 72 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 73 |
+
Penggunaan Token Hari Ini
|
| 74 |
+
</CardTitle>
|
| 75 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 76 |
+
Total token yang terpakai untuk generate soal hari ini.
|
| 77 |
+
</CardDescription>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</CardHeader>
|
| 81 |
+
<CardContent className="space-y-6">
|
| 82 |
+
<div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
|
| 83 |
+
<MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
|
| 84 |
+
<div>
|
| 85 |
+
<p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
|
| 86 |
+
<p className="text-3xl font-extrabold text-[var(--matcha-800)]">
|
| 87 |
+
{isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
|
| 88 |
+
</p>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div>
|
| 93 |
+
<h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
|
| 94 |
+
Riwayat Generate Hari Ini
|
| 95 |
+
</h3>
|
| 96 |
+
{isLoading ? (
|
| 97 |
+
<div className="space-y-2">
|
| 98 |
+
{[1, 2, 3].map((i) => (
|
| 99 |
+
<div key={i} className="h-12 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
|
| 100 |
+
))}
|
| 101 |
+
</div>
|
| 102 |
+
) : jobs.length === 0 ? (
|
| 103 |
+
<div className="text-center py-8 border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-lg)]">
|
| 104 |
+
<MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 105 |
+
<p className="text-[var(--warm-charcoal)] font-semibold">Belum ada generate hari ini</p>
|
| 106 |
+
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 107 |
+
Generate soal baru untuk melihat penggunaan token.
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
) : (
|
| 111 |
+
<div className="overflow-x-auto">
|
| 112 |
+
<table className="w-full text-sm">
|
| 113 |
+
<thead>
|
| 114 |
+
<tr className="border-b border-[var(--oat-border)] text-[var(--warm-charcoal)]">
|
| 115 |
+
<th className="text-left py-2 px-3 font-medium">Waktu</th>
|
| 116 |
+
<th className="text-left py-2 px-3 font-medium">Mode</th>
|
| 117 |
+
<th className="text-left py-2 px-3 font-medium">Status</th>
|
| 118 |
+
<th className="text-left py-2 px-3 font-medium">Ujian</th>
|
| 119 |
+
<th className="text-left py-2 px-3 font-medium">Section</th>
|
| 120 |
+
<th className="text-right py-2 px-3 font-medium">Soal</th>
|
| 121 |
+
<th className="text-right py-2 px-3 font-medium">Token</th>
|
| 122 |
+
</tr>
|
| 123 |
+
</thead>
|
| 124 |
+
<tbody>
|
| 125 |
+
{jobs.map((job) => {
|
| 126 |
+
const isFailed = job.status === "failed";
|
| 127 |
+
const isCancelled = job.status === "cancelled";
|
| 128 |
+
return (
|
| 129 |
+
<tr
|
| 130 |
+
key={job.id}
|
| 131 |
+
className={`border-b border-[var(--oat-border)] last:border-0 hover:bg-[var(--warm-cream)] transition-colors ${
|
| 132 |
+
isFailed || isCancelled ? "opacity-70" : ""
|
| 133 |
+
}`}
|
| 134 |
+
>
|
| 135 |
+
<td className="py-2.5 px-3 text-[var(--clay-black)] whitespace-nowrap">
|
| 136 |
+
{new Date(job.createdAt).toLocaleTimeString("id-ID", {
|
| 137 |
+
hour: "2-digit",
|
| 138 |
+
minute: "2-digit",
|
| 139 |
+
})}
|
| 140 |
+
</td>
|
| 141 |
+
<td className="py-2.5 px-3">
|
| 142 |
+
<span
|
| 143 |
+
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
|
| 144 |
+
job.mode === "agentic"
|
| 145 |
+
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 146 |
+
: "bg-[var(--lavender-300)] text-[var(--lavender-800)]"
|
| 147 |
+
}`}
|
| 148 |
+
>
|
| 149 |
+
{job.mode === "agentic" ? "Agentic" : "Quick"}
|
| 150 |
+
</span>
|
| 151 |
+
</td>
|
| 152 |
+
<td className="py-2.5 px-3">
|
| 153 |
+
{job.status === "completed" ? (
|
| 154 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--matcha-800)]">
|
| 155 |
+
<MaterialIcon name="check_circle" className="text-xs" />
|
| 156 |
+
Selesai
|
| 157 |
+
</span>
|
| 158 |
+
) : isFailed ? (
|
| 159 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--pomegranate-400)]">
|
| 160 |
+
<MaterialIcon name="error" className="text-xs" />
|
| 161 |
+
Gagal
|
| 162 |
+
</span>
|
| 163 |
+
) : isCancelled ? (
|
| 164 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-charcoal)]">
|
| 165 |
+
<MaterialIcon name="cancel" className="text-xs" />
|
| 166 |
+
Dibatalkan
|
| 167 |
+
</span>
|
| 168 |
+
) : (
|
| 169 |
+
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-silver)]">
|
| 170 |
+
<MaterialIcon name="hourglass_empty" className="text-xs" />
|
| 171 |
+
{job.status}
|
| 172 |
+
</span>
|
| 173 |
+
)}
|
| 174 |
+
</td>
|
| 175 |
+
<td className="py-2.5 px-3 text-[var(--clay-black)]">{job.examTypeId}</td>
|
| 176 |
+
<td className="py-2.5 px-3 text-[var(--clay-black)]">{job.sectionTypeId}</td>
|
| 177 |
+
<td className="py-2.5 px-3 text-right text-[var(--clay-black)]">{job.questionCount}</td>
|
| 178 |
+
<td className="py-2.5 px-3 text-right text-[var(--clay-black)] font-medium">
|
| 179 |
+
{job.tokensUsed?.toLocaleString("id-ID") ?? "-"}
|
| 180 |
+
</td>
|
| 181 |
+
</tr>
|
| 182 |
+
);
|
| 183 |
+
})}
|
| 184 |
+
</tbody>
|
| 185 |
+
</table>
|
| 186 |
+
</div>
|
| 187 |
+
)}
|
| 188 |
+
</div>
|
| 189 |
+
</CardContent>
|
| 190 |
+
</Card>
|
| 191 |
+
</div>
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
export function RouteComponent() {
|
| 196 |
+
const { configs, isLoading, addConfig, updateConfig, removeConfig } =
|
| 197 |
+
useApiKeys();
|
| 198 |
+
|
| 199 |
+
const search = Route.useSearch();
|
| 200 |
+
const navigate = Route.useNavigate();
|
| 201 |
+
|
| 202 |
+
const [editingId, setEditingId] = useState<string | null>(null);
|
| 203 |
+
const [isAdding, setIsAdding] = useState(false);
|
| 204 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 205 |
+
|
| 206 |
+
const activeTab: Tab = search.tab ?? "api-keys";
|
| 207 |
+
|
| 208 |
+
const setTab = (tab: string) => {
|
| 209 |
+
startTransition(() => {
|
| 210 |
+
navigate({ search: { tab: tab as Tab } });
|
| 211 |
+
});
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>(
|
| 215 |
+
() => ({
|
| 216 |
+
...defaultConfig(),
|
| 217 |
+
apiKey: "",
|
| 218 |
+
}),
|
| 219 |
+
);
|
| 220 |
+
|
| 221 |
+
const resetForm = () => {
|
| 222 |
+
setForm({ ...defaultConfig(), apiKey: "" });
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
const handleFormChange = (field: keyof typeof form, value: string | number) => {
|
| 226 |
+
setForm((prev) => ({ ...prev, [field]: value }));
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
const startAdd = () => {
|
| 230 |
+
resetForm();
|
| 231 |
+
setIsAdding(true);
|
| 232 |
+
setEditingId(null);
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
const startEdit = (config: ApiKeyConfig) => {
|
| 236 |
+
setForm({
|
| 237 |
+
name: config.name,
|
| 238 |
+
provider: config.provider,
|
| 239 |
+
baseUrl: config.baseUrl,
|
| 240 |
+
modelName: config.modelName,
|
| 241 |
+
maxTokens: config.maxTokens ?? 16384,
|
| 242 |
+
apiKey: "",
|
| 243 |
+
});
|
| 244 |
+
setEditingId(config.id);
|
| 245 |
+
setIsAdding(false);
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
+
const cancelEdit = () => {
|
| 249 |
+
setEditingId(null);
|
| 250 |
+
setIsAdding(false);
|
| 251 |
+
resetForm();
|
| 252 |
+
};
|
| 253 |
+
|
| 254 |
+
const handleSave = async () => {
|
| 255 |
+
if (!form.name.trim()) return;
|
| 256 |
+
setIsSaving(true);
|
| 257 |
+
try {
|
| 258 |
+
if (isAdding) {
|
| 259 |
+
if (!form.apiKey) return;
|
| 260 |
+
await addConfig(form);
|
| 261 |
+
setIsAdding(false);
|
| 262 |
+
} else if (editingId) {
|
| 263 |
+
await updateConfig(editingId, form);
|
| 264 |
+
setEditingId(null);
|
| 265 |
+
}
|
| 266 |
+
resetForm();
|
| 267 |
+
} finally {
|
| 268 |
+
setIsSaving(false);
|
| 269 |
+
}
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const isFormOpen = isAdding || editingId !== null;
|
| 273 |
+
const canSave =
|
| 274 |
+
!!form.name.trim() &&
|
| 275 |
+
!!form.baseUrl.trim() &&
|
| 276 |
+
!!form.modelName.trim() &&
|
| 277 |
+
(isAdding ? !!form.apiKey : true);
|
| 278 |
+
|
| 279 |
+
return (
|
| 280 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 281 |
+
<section className="mb-10">
|
| 282 |
+
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 283 |
+
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
|
| 284 |
+
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 285 |
+
<span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
|
| 286 |
+
</div>
|
| 287 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 288 |
+
Pengaturan
|
| 289 |
+
</h1>
|
| 290 |
+
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 291 |
+
Kelola API key, pantau penggunaan token, dan preferensi akun.
|
| 292 |
+
</p>
|
| 293 |
+
</section>
|
| 294 |
+
|
| 295 |
+
<Tabs value={activeTab} onValueChange={setTab} className="w-full">
|
| 296 |
+
<TabsList variant="line" className="mb-6">
|
| 297 |
+
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
|
| 298 |
+
<TabsTrigger value="token-usage">Token Usage</TabsTrigger>
|
| 299 |
+
<TabsTrigger value="account">Akun</TabsTrigger>
|
| 300 |
+
<TabsTrigger value="security">Keamanan</TabsTrigger>
|
| 301 |
+
</TabsList>
|
| 302 |
+
|
| 303 |
+
<TabsContent value="api-keys" className="space-y-8">
|
| 304 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 305 |
+
<div className="lg:col-span-7 space-y-8">
|
| 306 |
+
<ApiKeyList
|
| 307 |
+
configs={configs}
|
| 308 |
+
isLoading={isLoading}
|
| 309 |
+
editingId={editingId}
|
| 310 |
+
isFormOpen={isFormOpen}
|
| 311 |
+
providers={PROVIDERS}
|
| 312 |
+
onStartAdd={startAdd}
|
| 313 |
+
onStartEdit={startEdit}
|
| 314 |
+
onRemove={removeConfig}
|
| 315 |
+
/>
|
| 316 |
+
|
| 317 |
+
{isFormOpen && (
|
| 318 |
+
<ApiKeyForm
|
| 319 |
+
isAdding={isAdding}
|
| 320 |
+
isSaving={isSaving}
|
| 321 |
+
form={form}
|
| 322 |
+
canSave={canSave}
|
| 323 |
+
providers={PROVIDERS}
|
| 324 |
+
onChange={handleFormChange}
|
| 325 |
+
onSave={handleSave}
|
| 326 |
+
onCancel={cancelEdit}
|
| 327 |
+
/>
|
| 328 |
+
)}
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
<div className="lg:col-span-5">
|
| 332 |
+
<TipsCard />
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
</TabsContent>
|
| 336 |
+
|
| 337 |
+
<TabsContent value="token-usage">
|
| 338 |
+
<TokenUsageSection />
|
| 339 |
+
</TabsContent>
|
| 340 |
+
|
| 341 |
+
<TabsContent value="account">
|
| 342 |
+
<AccountSettings />
|
| 343 |
+
</TabsContent>
|
| 344 |
+
|
| 345 |
+
<TabsContent value="security">
|
| 346 |
+
<SecurityInfo />
|
| 347 |
+
</TabsContent>
|
| 348 |
+
</Tabs>
|
| 349 |
+
</div>
|
| 350 |
+
);
|
| 351 |
+
}
|
apps/web/src/routeTree.gen.ts
CHANGED
|
@@ -52,12 +52,12 @@ const SettingsRoute = SettingsRouteImport.update({
|
|
| 52 |
id: '/settings',
|
| 53 |
path: '/settings',
|
| 54 |
getParentRoute: () => rootRouteImport,
|
| 55 |
-
} as any)
|
| 56 |
const PackagesRoute = PackagesRouteImport.update({
|
| 57 |
id: '/packages',
|
| 58 |
path: '/packages',
|
| 59 |
getParentRoute: () => rootRouteImport,
|
| 60 |
-
} as any)
|
| 61 |
const MeRoute = MeRouteImport.update({
|
| 62 |
id: '/me',
|
| 63 |
path: '/me',
|
|
@@ -77,7 +77,7 @@ const LandingRoute = LandingRouteImport.update({
|
|
| 77 |
id: '/landing',
|
| 78 |
path: '/landing',
|
| 79 |
getParentRoute: () => rootRouteImport,
|
| 80 |
-
} as any)
|
| 81 |
const JobsRoute = JobsRouteImport.update({
|
| 82 |
id: '/jobs',
|
| 83 |
path: '/jobs',
|
|
@@ -92,7 +92,7 @@ const GenerateRoute = GenerateRouteImport.update({
|
|
| 92 |
id: '/generate',
|
| 93 |
path: '/generate',
|
| 94 |
getParentRoute: () => rootRouteImport,
|
| 95 |
-
} as any)
|
| 96 |
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
| 97 |
id: '/forgot-password',
|
| 98 |
path: '/forgot-password',
|
|
@@ -102,7 +102,7 @@ const BankRoute = BankRouteImport.update({
|
|
| 102 |
id: '/bank',
|
| 103 |
path: '/bank',
|
| 104 |
getParentRoute: () => rootRouteImport,
|
| 105 |
-
} as any)
|
| 106 |
const AnalyticsRoute = AnalyticsRouteImport.update({
|
| 107 |
id: '/analytics',
|
| 108 |
path: '/analytics',
|
|
|
|
| 52 |
id: '/settings',
|
| 53 |
path: '/settings',
|
| 54 |
getParentRoute: () => rootRouteImport,
|
| 55 |
+
} as any).lazy(() => import('./routes/settings.lazy').then((d) => d.Route))
|
| 56 |
const PackagesRoute = PackagesRouteImport.update({
|
| 57 |
id: '/packages',
|
| 58 |
path: '/packages',
|
| 59 |
getParentRoute: () => rootRouteImport,
|
| 60 |
+
} as any).lazy(() => import('./routes/packages.lazy').then((d) => d.Route))
|
| 61 |
const MeRoute = MeRouteImport.update({
|
| 62 |
id: '/me',
|
| 63 |
path: '/me',
|
|
|
|
| 77 |
id: '/landing',
|
| 78 |
path: '/landing',
|
| 79 |
getParentRoute: () => rootRouteImport,
|
| 80 |
+
} as any).lazy(() => import('./routes/landing.lazy').then((d) => d.Route))
|
| 81 |
const JobsRoute = JobsRouteImport.update({
|
| 82 |
id: '/jobs',
|
| 83 |
path: '/jobs',
|
|
|
|
| 92 |
id: '/generate',
|
| 93 |
path: '/generate',
|
| 94 |
getParentRoute: () => rootRouteImport,
|
| 95 |
+
} as any).lazy(() => import('./routes/generate.lazy').then((d) => d.Route))
|
| 96 |
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
| 97 |
id: '/forgot-password',
|
| 98 |
path: '/forgot-password',
|
|
|
|
| 102 |
id: '/bank',
|
| 103 |
path: '/bank',
|
| 104 |
getParentRoute: () => rootRouteImport,
|
| 105 |
+
} as any).lazy(() => import('./routes/bank.lazy').then((d) => d.Route))
|
| 106 |
const AnalyticsRoute = AnalyticsRouteImport.update({
|
| 107 |
id: '/analytics',
|
| 108 |
path: '/analytics',
|
apps/web/src/routes/bank.lazy.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { BankComponent } from "@/components/routes/BankPage";
|
| 3 |
+
|
| 4 |
+
export const Route = createLazyFileRoute("/bank")({
|
| 5 |
+
component: BankComponent,
|
| 6 |
+
});
|
apps/web/src/routes/bank.tsx
CHANGED
|
@@ -1,30 +1,8 @@
|
|
| 1 |
-
import { useState, useEffect } from "react";
|
| 2 |
-
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 4 |
import { z } from "zod";
|
| 5 |
import { authClient } from "@/lib/auth-client";
|
| 6 |
-
import { trpc, queryClient } from "@/utils/trpc";
|
| 7 |
-
import { EXAM_TYPES, SECTIONS } from "@/lib/exam-constants";
|
| 8 |
-
import { formatLabel } from "@/lib/format";
|
| 9 |
-
import { usePackageBuilder } from "@/hooks/use-package-builder";
|
| 10 |
-
import { useLocalStorageBoolean } from "@/hooks/use-local-storage-boolean";
|
| 11 |
-
import { AutoBundleModal } from "@/components/bank/AutoBundleModal";
|
| 12 |
-
import { QuestionDetailModal } from "@/components/bank/QuestionDetailModal";
|
| 13 |
-
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 14 |
-
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 15 |
-
import { FilterBar } from "@/components/bank/FilterBar";
|
| 16 |
-
import { MobileFilterSheet } from "@/components/bank/MobileFilterSheet";
|
| 17 |
-
import { AdvancedFilters } from "@/components/bank/AdvancedFilters";
|
| 18 |
-
import { SoalBrowser } from "@/components/bank/SoalBrowser";
|
| 19 |
-
import { SectionBrowser } from "@/components/bank/SectionBrowser";
|
| 20 |
-
import { BundleSidebar } from "@/components/bank/BundleSidebar";
|
| 21 |
-
import { toast } from "sonner";
|
| 22 |
-
import type { Step } from "react-joyride";
|
| 23 |
-
|
| 24 |
-
const FILTER_ADVANCED_KEY = "labas-bank-filter-advanced";
|
| 25 |
|
| 26 |
export const Route = createFileRoute("/bank")({
|
| 27 |
-
component: BankComponent,
|
| 28 |
validateSearch: z.object({
|
| 29 |
mode: z.enum(["soal", "section"]).optional(),
|
| 30 |
tab: z.enum(["mine", "public"]).optional(),
|
|
@@ -43,537 +21,3 @@ export const Route = createFileRoute("/bank")({
|
|
| 43 |
return { session };
|
| 44 |
},
|
| 45 |
});
|
| 46 |
-
|
| 47 |
-
type QuestionTab = "mine" | "public";
|
| 48 |
-
type Mode = "soal" | "section";
|
| 49 |
-
|
| 50 |
-
function BankComponent() {
|
| 51 |
-
const search = Route.useSearch();
|
| 52 |
-
const navigate = Route.useNavigate();
|
| 53 |
-
const { data: session } = authClient.useSession();
|
| 54 |
-
const userId = session?.user.id;
|
| 55 |
-
|
| 56 |
-
const mode: Mode = search.mode ?? "soal";
|
| 57 |
-
const tab: QuestionTab = search.tab ?? "public";
|
| 58 |
-
const searchText = search.search ?? "";
|
| 59 |
-
const examType = search.examType ?? "";
|
| 60 |
-
const section = search.section ?? "";
|
| 61 |
-
const format = search.format ?? "";
|
| 62 |
-
const difficulty = search.difficulty;
|
| 63 |
-
const visibilityFilter = search.visibility ?? "all";
|
| 64 |
-
|
| 65 |
-
// ── Infinite scroll state ──
|
| 66 |
-
const [allQuestions, setAllQuestions] = useState<any[]>([]);
|
| 67 |
-
const [offset, setOffset] = useState(0);
|
| 68 |
-
const limit = 12;
|
| 69 |
-
const filterKey = JSON.stringify({ searchText, examType, section, format, difficulty, tab, mode, visibility: visibilityFilter });
|
| 70 |
-
|
| 71 |
-
// ── Sidebar / Bundle State ──
|
| 72 |
-
const [bundleQuestions, setBundleQuestions] = useState<any[]>([]);
|
| 73 |
-
const [bundleSections, setBundleSections] = useState<any[]>([]);
|
| 74 |
-
const [bundleTitle, setBundleTitle] = useState("");
|
| 75 |
-
const [bundleDescription, setBundleDescription] = useState("");
|
| 76 |
-
const [bundleIsPublic, setBundleIsPublic] = useState(false);
|
| 77 |
-
|
| 78 |
-
// ── Modals ──
|
| 79 |
-
const [selectedQuestion, setSelectedQuestion] = useState<any | null>(null);
|
| 80 |
-
const [isAutoBundleOpen, setIsAutoBundleOpen] = useState(false);
|
| 81 |
-
|
| 82 |
-
// ── Filter UI State ──
|
| 83 |
-
const [isAdvancedOpen, setIsAdvancedOpen] = useLocalStorageBoolean(FILTER_ADVANCED_KEY, false);
|
| 84 |
-
const [isMobileSheetOpen, setIsMobileSheetOpen] = useState(false);
|
| 85 |
-
|
| 86 |
-
// ── Data Queries ──
|
| 87 |
-
const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
|
| 88 |
-
? { isPublic: visibilityFilter === "public" }
|
| 89 |
-
: {};
|
| 90 |
-
|
| 91 |
-
const questionQuery = useQuery(
|
| 92 |
-
trpc.question.list.queryOptions(
|
| 93 |
-
{
|
| 94 |
-
search: searchText || undefined,
|
| 95 |
-
examTypeId: examType || undefined,
|
| 96 |
-
sectionTypeId: section || undefined,
|
| 97 |
-
format: format || undefined,
|
| 98 |
-
difficulty,
|
| 99 |
-
...(tab === "mine" && userId
|
| 100 |
-
? { creatorUserId: userId, ...visibilityFilterParam }
|
| 101 |
-
: { isPublic: true }),
|
| 102 |
-
limit,
|
| 103 |
-
offset,
|
| 104 |
-
},
|
| 105 |
-
{ enabled: mode === "soal" },
|
| 106 |
-
),
|
| 107 |
-
);
|
| 108 |
-
|
| 109 |
-
// Reset offset when filters change
|
| 110 |
-
useEffect(() => {
|
| 111 |
-
setOffset(0);
|
| 112 |
-
}, [filterKey]);
|
| 113 |
-
|
| 114 |
-
// Append / replace questions when query data arrives
|
| 115 |
-
useEffect(() => {
|
| 116 |
-
const data = questionQuery.data;
|
| 117 |
-
if (!data) return;
|
| 118 |
-
if (offset === 0) {
|
| 119 |
-
setAllQuestions(data.questions ?? []);
|
| 120 |
-
} else {
|
| 121 |
-
setAllQuestions((prev) => {
|
| 122 |
-
const existingIds = new Set(prev.map((q: any) => q.id));
|
| 123 |
-
const newQs = (data.questions ?? []).filter((q: any) => !existingIds.has(q.id));
|
| 124 |
-
return [...prev, ...newQs];
|
| 125 |
-
});
|
| 126 |
-
}
|
| 127 |
-
}, [questionQuery.data]);
|
| 128 |
-
|
| 129 |
-
const totalQuestions = questionQuery.data?.total ?? 0;
|
| 130 |
-
const hasMore = offset + limit < totalQuestions;
|
| 131 |
-
|
| 132 |
-
const sectionQuery = useQuery(
|
| 133 |
-
trpc.combo.availableSections.queryOptions(
|
| 134 |
-
{
|
| 135 |
-
examTypeId: examType || undefined,
|
| 136 |
-
search: searchText || undefined,
|
| 137 |
-
limit: 50,
|
| 138 |
-
offset: 0,
|
| 139 |
-
},
|
| 140 |
-
{ enabled: mode === "section" },
|
| 141 |
-
),
|
| 142 |
-
);
|
| 143 |
-
|
| 144 |
-
// ── Mutations ──
|
| 145 |
-
const { isPending: isPackagePending, handleAutoBundle } = usePackageBuilder();
|
| 146 |
-
|
| 147 |
-
const createPackage = useMutation(trpc.package.create.mutationOptions());
|
| 148 |
-
const addSection = useMutation(trpc.package.addSection.mutationOptions());
|
| 149 |
-
const addQuestion = useMutation(trpc.package.addQuestion.mutationOptions());
|
| 150 |
-
const createCombo = useMutation(trpc.combo.create.mutationOptions());
|
| 151 |
-
|
| 152 |
-
const togglePublic = useMutation({
|
| 153 |
-
...trpc.question.togglePublic.mutationOptions(),
|
| 154 |
-
onSuccess: () => questionQuery.refetch(),
|
| 155 |
-
});
|
| 156 |
-
|
| 157 |
-
const deleteQuestion = useMutation({
|
| 158 |
-
...trpc.question.delete.mutationOptions(),
|
| 159 |
-
onSuccess: () => questionQuery.refetch(),
|
| 160 |
-
});
|
| 161 |
-
|
| 162 |
-
const bulkPublish = useMutation({
|
| 163 |
-
...trpc.question.bulkPublish.mutationOptions(),
|
| 164 |
-
onSuccess: (data) => {
|
| 165 |
-
questionQuery.refetch();
|
| 166 |
-
if (data.skipped > 0) {
|
| 167 |
-
toast.success(
|
| 168 |
-
`${data.updated} soal dipublikasikan, ${data.skipped} dilewati`,
|
| 169 |
-
{ description: "Beberapa soal bukan milikmu atau sudah tidak tersedia." },
|
| 170 |
-
);
|
| 171 |
-
} else {
|
| 172 |
-
toast.success(`${data.updated} soal berhasil dipublikasikan`);
|
| 173 |
-
}
|
| 174 |
-
},
|
| 175 |
-
onError: (err: any) => {
|
| 176 |
-
toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang soal.", { description: err.message });
|
| 177 |
-
},
|
| 178 |
-
});
|
| 179 |
-
|
| 180 |
-
// ── Navigation helpers ──
|
| 181 |
-
const setMode = (newMode: Mode) => {
|
| 182 |
-
navigate({
|
| 183 |
-
search: {
|
| 184 |
-
mode: newMode,
|
| 185 |
-
tab: newMode === "soal" ? "mine" : undefined,
|
| 186 |
-
search: "",
|
| 187 |
-
examType: "",
|
| 188 |
-
section: "",
|
| 189 |
-
format: "",
|
| 190 |
-
difficulty: undefined,
|
| 191 |
-
},
|
| 192 |
-
});
|
| 193 |
-
if (newMode === "soal") setBundleSections([]);
|
| 194 |
-
else setBundleQuestions([]);
|
| 195 |
-
};
|
| 196 |
-
|
| 197 |
-
const setSearch = (value: string) =>
|
| 198 |
-
navigate({ search: (prev) => ({ ...prev, search: value }) });
|
| 199 |
-
|
| 200 |
-
const setExamType = (value: string) =>
|
| 201 |
-
navigate({ search: (prev) => ({ ...prev, examType: value }) });
|
| 202 |
-
|
| 203 |
-
const setSection = (value: string) =>
|
| 204 |
-
navigate({ search: (prev) => ({ ...prev, section: value }) });
|
| 205 |
-
|
| 206 |
-
const setFormat = (value: string) =>
|
| 207 |
-
navigate({ search: (prev) => ({ ...prev, format: value }) });
|
| 208 |
-
|
| 209 |
-
const setDifficulty = (value: number | undefined) =>
|
| 210 |
-
navigate({ search: (prev) => ({ ...prev, difficulty: value }) });
|
| 211 |
-
|
| 212 |
-
const setVisibility = (value: "all" | "private" | "public") =>
|
| 213 |
-
navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value }) });
|
| 214 |
-
|
| 215 |
-
const setTab = (newTab: QuestionTab) =>
|
| 216 |
-
navigate({
|
| 217 |
-
search: {
|
| 218 |
-
mode: "soal",
|
| 219 |
-
tab: newTab,
|
| 220 |
-
search: "",
|
| 221 |
-
examType: "",
|
| 222 |
-
section: "",
|
| 223 |
-
format: "",
|
| 224 |
-
difficulty: undefined,
|
| 225 |
-
visibility: undefined,
|
| 226 |
-
},
|
| 227 |
-
});
|
| 228 |
-
|
| 229 |
-
const clearFilters = () =>
|
| 230 |
-
navigate({
|
| 231 |
-
search: (prev) => ({
|
| 232 |
-
...prev,
|
| 233 |
-
search: "",
|
| 234 |
-
examType: "",
|
| 235 |
-
section: "",
|
| 236 |
-
format: "",
|
| 237 |
-
difficulty: undefined,
|
| 238 |
-
visibility: undefined,
|
| 239 |
-
}),
|
| 240 |
-
});
|
| 241 |
-
|
| 242 |
-
const hasFilters =
|
| 243 |
-
!!searchText || !!examType || !!section || !!format || difficulty !== undefined;
|
| 244 |
-
|
| 245 |
-
// ── Active filter chips data ──
|
| 246 |
-
const activeChips = [
|
| 247 |
-
...(examType ? [{ key: "examType", label: EXAM_TYPES.find((t) => t.id === examType)?.name ?? examType, onRemove: () => setExamType("") }] : []),
|
| 248 |
-
...(section ? [{ key: "section", label: SECTIONS.find((s) => s.id === section)?.name ?? section, onRemove: () => setSection("") }] : []),
|
| 249 |
-
...(format ? [{ key: "format", label: formatLabel(format), onRemove: () => setFormat("") }] : []),
|
| 250 |
-
...(difficulty !== undefined ? [{ key: "difficulty", label: `Lv.${difficulty}`, onRemove: () => setDifficulty(undefined) }] : []),
|
| 251 |
-
];
|
| 252 |
-
|
| 253 |
-
// ── Bundle helpers ──
|
| 254 |
-
const lockedExamType = bundleQuestions.length > 0 ? bundleQuestions[0]?.examTypeId : null;
|
| 255 |
-
|
| 256 |
-
const isQuestionInBundle = (qid: string) =>
|
| 257 |
-
bundleQuestions.some((q) => q.id === qid);
|
| 258 |
-
|
| 259 |
-
const isSectionInBundle = (sid: string) =>
|
| 260 |
-
bundleSections.some((s) => s.id === sid);
|
| 261 |
-
|
| 262 |
-
const toggleQuestion = (q: any) => {
|
| 263 |
-
if (lockedExamType && q.examTypeId !== lockedExamType) {
|
| 264 |
-
toast.error(`Hanya bisa memilih soal dari ${EXAM_TYPES.find((t) => t.id === lockedExamType)?.name ?? lockedExamType}`);
|
| 265 |
-
return;
|
| 266 |
-
}
|
| 267 |
-
setBundleQuestions((prev) => {
|
| 268 |
-
const exists = prev.find((x) => x.id === q.id);
|
| 269 |
-
if (exists) return prev.filter((x) => x.id !== q.id);
|
| 270 |
-
return [...prev, q];
|
| 271 |
-
});
|
| 272 |
-
};
|
| 273 |
-
|
| 274 |
-
const toggleSection = (s: any) => {
|
| 275 |
-
setBundleSections((prev) => {
|
| 276 |
-
const exists = prev.find((x) => x.id === s.id);
|
| 277 |
-
if (exists) return prev.filter((x) => x.id !== s.id);
|
| 278 |
-
return [...prev, s];
|
| 279 |
-
});
|
| 280 |
-
};
|
| 281 |
-
|
| 282 |
-
const removeFromBundle = (id: string, type: "question" | "section") => {
|
| 283 |
-
if (type === "question") {
|
| 284 |
-
setBundleQuestions((prev) => prev.filter((x) => x.id !== id));
|
| 285 |
-
} else {
|
| 286 |
-
setBundleSections((prev) => prev.filter((x) => x.id !== id));
|
| 287 |
-
}
|
| 288 |
-
};
|
| 289 |
-
|
| 290 |
-
// ── Create handlers ──
|
| 291 |
-
const handleCreateFromQuestions = async () => {
|
| 292 |
-
if (!bundleTitle || bundleQuestions.length === 0) return;
|
| 293 |
-
const first = bundleQuestions[0];
|
| 294 |
-
try {
|
| 295 |
-
const pkg = await createPackage.mutateAsync({
|
| 296 |
-
title: bundleTitle,
|
| 297 |
-
description: bundleDescription,
|
| 298 |
-
examTypeId: first?.examTypeId ?? "",
|
| 299 |
-
isPublic: bundleIsPublic,
|
| 300 |
-
estimatedDurationMin: bundleQuestions.length * 2,
|
| 301 |
-
});
|
| 302 |
-
const sec = await addSection.mutateAsync({
|
| 303 |
-
packageId: pkg.id,
|
| 304 |
-
sectionTypeId: first?.sectionTypeId ?? "READING",
|
| 305 |
-
title: `${first?.sectionTypeName ?? "Reading"} Section`,
|
| 306 |
-
orderIndex: 0,
|
| 307 |
-
});
|
| 308 |
-
for (let i = 0; i < bundleQuestions.length; i++) {
|
| 309 |
-
await addQuestion.mutateAsync({
|
| 310 |
-
sectionId: sec.id,
|
| 311 |
-
questionId: bundleQuestions[i].id,
|
| 312 |
-
orderIndex: i,
|
| 313 |
-
});
|
| 314 |
-
}
|
| 315 |
-
setBundleQuestions([]);
|
| 316 |
-
setBundleTitle("");
|
| 317 |
-
setBundleDescription("");
|
| 318 |
-
} catch (err: any) {
|
| 319 |
-
toast.error("Gagal membuat paket", { description: err.message });
|
| 320 |
-
}
|
| 321 |
-
};
|
| 322 |
-
|
| 323 |
-
const handleCreateFromSections = async () => {
|
| 324 |
-
if (!bundleTitle || bundleSections.length === 0) return;
|
| 325 |
-
try {
|
| 326 |
-
await createCombo.mutateAsync({
|
| 327 |
-
title: bundleTitle,
|
| 328 |
-
description: bundleDescription,
|
| 329 |
-
isPublic: bundleIsPublic,
|
| 330 |
-
sections: bundleSections.map((s, i) => ({
|
| 331 |
-
sourcePackageId: s.packageId,
|
| 332 |
-
sourceSectionId: s.id,
|
| 333 |
-
orderIndex: i,
|
| 334 |
-
})),
|
| 335 |
-
});
|
| 336 |
-
setBundleSections([]);
|
| 337 |
-
setBundleTitle("");
|
| 338 |
-
setBundleDescription("");
|
| 339 |
-
toast.success("Combo paket berhasil dibuat!");
|
| 340 |
-
} catch (err: any) {
|
| 341 |
-
toast.error("Gagal membuat combo", { description: err.message });
|
| 342 |
-
}
|
| 343 |
-
};
|
| 344 |
-
|
| 345 |
-
// ── Auto Bundle ──
|
| 346 |
-
const autoBundleExamType = examType || lockedExamType || null;
|
| 347 |
-
const autoBundleSectionType = section || null;
|
| 348 |
-
|
| 349 |
-
const onAutoBundle = async (data: {
|
| 350 |
-
title: string;
|
| 351 |
-
description: string;
|
| 352 |
-
isPublic: boolean;
|
| 353 |
-
count: number;
|
| 354 |
-
sortOrder: "random" | "difficulty";
|
| 355 |
-
}) => {
|
| 356 |
-
const batchSize = 50;
|
| 357 |
-
const baseInput = {
|
| 358 |
-
search: searchText || undefined,
|
| 359 |
-
examTypeId: examType || undefined,
|
| 360 |
-
sectionTypeId: section || undefined,
|
| 361 |
-
format: format || undefined,
|
| 362 |
-
difficulty,
|
| 363 |
-
...(tab === "mine" && userId
|
| 364 |
-
? { creatorUserId: userId }
|
| 365 |
-
: { isPublic: true }),
|
| 366 |
-
limit: batchSize,
|
| 367 |
-
};
|
| 368 |
-
const firstPage = await queryClient.fetchQuery(
|
| 369 |
-
trpc.question.list.queryOptions({ ...baseInput, offset: 0 }),
|
| 370 |
-
);
|
| 371 |
-
const allQuestions = [...(firstPage.questions ?? [])];
|
| 372 |
-
const totalAvailable = firstPage.total ?? allQuestions.length;
|
| 373 |
-
for (let offset = batchSize; offset < totalAvailable; offset += batchSize) {
|
| 374 |
-
const pageData = await queryClient.fetchQuery(
|
| 375 |
-
trpc.question.list.queryOptions({ ...baseInput, offset }),
|
| 376 |
-
);
|
| 377 |
-
allQuestions.push(...(pageData.questions ?? []));
|
| 378 |
-
}
|
| 379 |
-
await handleAutoBundle({
|
| 380 |
-
...data,
|
| 381 |
-
examTypeId: autoBundleExamType ?? "",
|
| 382 |
-
sectionTypeId: autoBundleSectionType ?? "READING",
|
| 383 |
-
allQuestions,
|
| 384 |
-
});
|
| 385 |
-
setIsAutoBundleOpen(false);
|
| 386 |
-
};
|
| 387 |
-
|
| 388 |
-
// ── Render helpers ──
|
| 389 |
-
const questions = allQuestions;
|
| 390 |
-
|
| 391 |
-
const sections = sectionQuery.data?.sections ?? [];
|
| 392 |
-
const groupedSections = sections.reduce((groups: Record<string, any[]>, s: any) => {
|
| 393 |
-
const key = `${s.examTypeName ?? "Unknown"} — ${s.packageTitle ?? "Untitled"}`;
|
| 394 |
-
if (!groups[key]) groups[key] = [];
|
| 395 |
-
groups[key].push(s);
|
| 396 |
-
return groups;
|
| 397 |
-
}, {} as Record<string, any[]>);
|
| 398 |
-
|
| 399 |
-
const isCreating =
|
| 400 |
-
createPackage.isPending ||
|
| 401 |
-
addSection.isPending ||
|
| 402 |
-
addQuestion.isPending ||
|
| 403 |
-
createCombo.isPending;
|
| 404 |
-
|
| 405 |
-
return (
|
| 406 |
-
<div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
|
| 407 |
-
<FilterBar
|
| 408 |
-
mode={mode}
|
| 409 |
-
tab={tab}
|
| 410 |
-
searchText={searchText}
|
| 411 |
-
examType={examType}
|
| 412 |
-
visibility={visibilityFilter}
|
| 413 |
-
activeChips={activeChips}
|
| 414 |
-
hasFilters={hasFilters}
|
| 415 |
-
isAdvancedOpen={isAdvancedOpen}
|
| 416 |
-
lockedExamType={lockedExamType}
|
| 417 |
-
dataTour="bank-filters"
|
| 418 |
-
onToggleAdvanced={() => setIsAdvancedOpen((v) => !v)}
|
| 419 |
-
onSetMode={setMode}
|
| 420 |
-
onSetTab={setTab}
|
| 421 |
-
onSetSearch={setSearch}
|
| 422 |
-
onSetExamType={setExamType}
|
| 423 |
-
onSetVisibility={setVisibility}
|
| 424 |
-
onClearFilters={clearFilters}
|
| 425 |
-
onOpenMobileSheet={() => setIsMobileSheetOpen(true)}
|
| 426 |
-
advancedFilters={
|
| 427 |
-
<AdvancedFilters
|
| 428 |
-
section={section}
|
| 429 |
-
format={format}
|
| 430 |
-
difficulty={difficulty}
|
| 431 |
-
onSetSection={setSection}
|
| 432 |
-
onSetFormat={setFormat}
|
| 433 |
-
onSetDifficulty={setDifficulty}
|
| 434 |
-
/>
|
| 435 |
-
}
|
| 436 |
-
/>
|
| 437 |
-
|
| 438 |
-
<div className="px-6 md:px-12 lg:px-16 max-w-7xl mx-auto pt-6">
|
| 439 |
-
<section className="mb-8">
|
| 440 |
-
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 441 |
-
Buat Paket
|
| 442 |
-
</h1>
|
| 443 |
-
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 444 |
-
Pilih soal atau section untuk dibuatkan paket latihan.
|
| 445 |
-
</p>
|
| 446 |
-
<div className="mt-3 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
|
| 447 |
-
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
|
| 448 |
-
<MaterialIcon name="auto_awesome" className="text-sm" />
|
| 449 |
-
Auto Bundle — biarkan AI pilihkan soal otomatis
|
| 450 |
-
</span>
|
| 451 |
-
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
|
| 452 |
-
<MaterialIcon name="touch_app" className="text-sm" />
|
| 453 |
-
Manual — pilih sendiri soal satu per satu
|
| 454 |
-
</span>
|
| 455 |
-
</div>
|
| 456 |
-
</section>
|
| 457 |
-
|
| 458 |
-
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
| 459 |
-
<div data-tour="bank-questions" className="lg:col-span-8">
|
| 460 |
-
{mode === "soal" ? (
|
| 461 |
-
<SoalBrowser
|
| 462 |
-
isLoading={questionQuery.isLoading}
|
| 463 |
-
questions={questions}
|
| 464 |
-
hasMore={hasMore}
|
| 465 |
-
isFetchingNextPage={questionQuery.isFetching}
|
| 466 |
-
onLoadMore={() => setOffset((prev) => prev + limit)}
|
| 467 |
-
hasFilters={hasFilters}
|
| 468 |
-
userId={userId}
|
| 469 |
-
lockedExamType={lockedExamType}
|
| 470 |
-
tab={tab}
|
| 471 |
-
filterKey={filterKey}
|
| 472 |
-
isQuestionInBundle={isQuestionInBundle}
|
| 473 |
-
onToggleQuestion={toggleQuestion}
|
| 474 |
-
onOpenDetail={setSelectedQuestion}
|
| 475 |
-
onTogglePublic={(id) => togglePublic.mutate({ id })}
|
| 476 |
-
onDelete={(id) => {
|
| 477 |
-
if (confirm("Yakin mau hapus soal ini?")) deleteQuestion.mutate({ id });
|
| 478 |
-
}}
|
| 479 |
-
onClearFilters={clearFilters}
|
| 480 |
-
onBulkPublish={(ids) => bulkPublish.mutate({ ids })}
|
| 481 |
-
onPublishAllPrivate={(ids) => bulkPublish.mutate({ ids })}
|
| 482 |
-
/>
|
| 483 |
-
) : (
|
| 484 |
-
<SectionBrowser
|
| 485 |
-
isLoading={sectionQuery.isLoading}
|
| 486 |
-
groupedSections={groupedSections}
|
| 487 |
-
isSectionInBundle={isSectionInBundle}
|
| 488 |
-
onToggleSection={toggleSection}
|
| 489 |
-
/>
|
| 490 |
-
)}
|
| 491 |
-
</div>
|
| 492 |
-
|
| 493 |
-
<BundleSidebar
|
| 494 |
-
mode={mode}
|
| 495 |
-
bundleQuestions={bundleQuestions}
|
| 496 |
-
bundleSections={bundleSections}
|
| 497 |
-
bundleTitle={bundleTitle}
|
| 498 |
-
bundleDescription={bundleDescription}
|
| 499 |
-
bundleIsPublic={bundleIsPublic}
|
| 500 |
-
isCreating={isCreating}
|
| 501 |
-
autoBundleExamType={autoBundleExamType}
|
| 502 |
-
lockedExamType={lockedExamType}
|
| 503 |
-
onSetTitle={setBundleTitle}
|
| 504 |
-
onSetDescription={setBundleDescription}
|
| 505 |
-
onSetIsPublic={setBundleIsPublic}
|
| 506 |
-
onRemoveFromBundle={removeFromBundle}
|
| 507 |
-
onCreateFromQuestions={handleCreateFromQuestions}
|
| 508 |
-
onCreateFromSections={handleCreateFromSections}
|
| 509 |
-
onOpenAutoBundle={() => setIsAutoBundleOpen(true)}
|
| 510 |
-
/>
|
| 511 |
-
</div>
|
| 512 |
-
</div>
|
| 513 |
-
|
| 514 |
-
<MobileFilterSheet
|
| 515 |
-
open={isMobileSheetOpen}
|
| 516 |
-
onOpenChange={setIsMobileSheetOpen}
|
| 517 |
-
section={section}
|
| 518 |
-
format={format}
|
| 519 |
-
difficulty={difficulty}
|
| 520 |
-
activeChips={activeChips}
|
| 521 |
-
onSetSection={setSection}
|
| 522 |
-
onSetFormat={setFormat}
|
| 523 |
-
onSetDifficulty={setDifficulty}
|
| 524 |
-
onClearFilters={clearFilters}
|
| 525 |
-
/>
|
| 526 |
-
|
| 527 |
-
{selectedQuestion && (
|
| 528 |
-
<QuestionDetailModal
|
| 529 |
-
question={selectedQuestion}
|
| 530 |
-
onClose={() => setSelectedQuestion(null)}
|
| 531 |
-
isSelected={isQuestionInBundle(selectedQuestion.id)}
|
| 532 |
-
onToggleSelect={() => toggleQuestion(selectedQuestion)}
|
| 533 |
-
isSelectable={true}
|
| 534 |
-
/>
|
| 535 |
-
)}
|
| 536 |
-
|
| 537 |
-
{isAutoBundleOpen && autoBundleExamType && (
|
| 538 |
-
<AutoBundleModal
|
| 539 |
-
availableCount={totalQuestions}
|
| 540 |
-
examTypeName={EXAM_TYPES.find((t) => t.id === autoBundleExamType)?.name ?? autoBundleExamType}
|
| 541 |
-
sectionTypeName={SECTIONS.find((s) => s.id === autoBundleSectionType)?.name ?? "Reading"}
|
| 542 |
-
onClose={() => setIsAutoBundleOpen(false)}
|
| 543 |
-
onCreate={onAutoBundle}
|
| 544 |
-
isPending={isPackagePending}
|
| 545 |
-
/>
|
| 546 |
-
)}
|
| 547 |
-
|
| 548 |
-
<PageTour
|
| 549 |
-
storageKey={BANK_TOUR_KEY}
|
| 550 |
-
autoDelay={600}
|
| 551 |
-
steps={bankPageSteps}
|
| 552 |
-
/>
|
| 553 |
-
<TourHelpButton storageKey={BANK_TOUR_KEY} />
|
| 554 |
-
</div>
|
| 555 |
-
);
|
| 556 |
-
}
|
| 557 |
-
|
| 558 |
-
// ── Bank page tour ──
|
| 559 |
-
const BANK_TOUR_KEY = "labas-page-tour-bank";
|
| 560 |
-
const bankPageSteps: Step[] = [
|
| 561 |
-
{
|
| 562 |
-
target: "[data-tour='bank-filters']",
|
| 563 |
-
title: "Filter & Mode",
|
| 564 |
-
content: "Pilih mode 'Dari Soal' untuk pilih soal satu per satu, atau 'Dari Section' untuk gabung section dari paket yang sudah ada. Filter juga berdasarkan exam type dan kata kunci.",
|
| 565 |
-
spotlightPadding: 8,
|
| 566 |
-
},
|
| 567 |
-
{
|
| 568 |
-
target: "[data-tour='bank-questions']",
|
| 569 |
-
title: "Daftar Soal",
|
| 570 |
-
content: "Semua soal yang sesuai filter ditampilkan di sini. Klik soal untuk melihat detail, atau centang untuk menambahkannya ke paket.",
|
| 571 |
-
spotlightPadding: 8,
|
| 572 |
-
},
|
| 573 |
-
{
|
| 574 |
-
target: "[data-tour='bank-sidebar']",
|
| 575 |
-
title: "Sidebar Paket",
|
| 576 |
-
content: "Soal yang dipilih muncul di sini. Atur judul, deskripsi, dan visibilitas paket. Klik 'Auto Bundle' untuk isi otomatis, atau 'Buat Paket' untuk simpan.",
|
| 577 |
-
spotlightPadding: 8,
|
| 578 |
-
},
|
| 579 |
-
];
|
|
|
|
|
|
|
|
|
|
| 1 |
import { createFileRoute, redirect } from "@tanstack/react-router";
|
| 2 |
import { z } from "zod";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export const Route = createFileRoute("/bank")({
|
|
|
|
| 6 |
validateSearch: z.object({
|
| 7 |
mode: z.enum(["soal", "section"]).optional(),
|
| 8 |
tab: z.enum(["mine", "public"]).optional(),
|
|
|
|
| 21 |
return { session };
|
| 22 |
},
|
| 23 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/web/src/routes/generate.lazy.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { RouteComponent } from "@/components/routes/GeneratePage";
|
| 3 |
+
|
| 4 |
+
export const Route = createLazyFileRoute("/generate")({
|
| 5 |
+
component: RouteComponent,
|
| 6 |
+
});
|
apps/web/src/routes/generate.tsx
CHANGED
|
@@ -1,38 +1,7 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { useMutation, useQuery } from "@tanstack/react-query";
|
| 3 |
-
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
| 5 |
-
import { trpc } from "@/utils/trpc";
|
| 6 |
-
import { useApiKeys } from "@/hooks/use-api-key";
|
| 7 |
-
import { useGenerationJobs, type CompletedResult } from "@/hooks/use-generation-jobs";
|
| 8 |
-
import { Button } from "@labas/ui/components/button";
|
| 9 |
-
import {
|
| 10 |
-
Select,
|
| 11 |
-
SelectContent,
|
| 12 |
-
SelectItem,
|
| 13 |
-
SelectTrigger,
|
| 14 |
-
SelectValue,
|
| 15 |
-
} from "@labas/ui/components/select";
|
| 16 |
-
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 17 |
-
import { TestBlueprintCard } from "@/components/generate/TestBlueprintCard";
|
| 18 |
-
import { ResultSection } from "@/components/generate/ResultSection";
|
| 19 |
-
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 20 |
-
import {
|
| 21 |
-
EXAM_TYPES,
|
| 22 |
-
SECTIONS,
|
| 23 |
-
FORMATS,
|
| 24 |
-
TOPICS,
|
| 25 |
-
DIFFICULTIES,
|
| 26 |
-
QUESTION_COUNT_PRESETS,
|
| 27 |
-
} from "@/lib/generate-constants";
|
| 28 |
-
import { getDifficultyLabel } from "@/lib/difficulty-mapping";
|
| 29 |
-
import "flag-icons/css/flag-icons.min.css";
|
| 30 |
-
import type { Step } from "react-joyride";
|
| 31 |
-
|
| 32 |
-
const MAX_PARALLEL = 3;
|
| 33 |
|
| 34 |
export const Route = createFileRoute("/generate")({
|
| 35 |
-
component: RouteComponent,
|
| 36 |
beforeLoad: async () => {
|
| 37 |
const session = await authClient.getSession();
|
| 38 |
if (!session.data) {
|
|
@@ -41,740 +10,3 @@ export const Route = createFileRoute("/generate")({
|
|
| 41 |
return { session };
|
| 42 |
},
|
| 43 |
});
|
| 44 |
-
|
| 45 |
-
function RouteComponent() {
|
| 46 |
-
const { configs, hasConfigs } = useApiKeys();
|
| 47 |
-
|
| 48 |
-
const [selectedKeyId, setSelectedKeyId] = useState<string>(
|
| 49 |
-
configs[0]?.id ?? "",
|
| 50 |
-
);
|
| 51 |
-
|
| 52 |
-
useEffect(() => {
|
| 53 |
-
if (configs.length > 0 && !configs.find((c) => c.id === selectedKeyId)) {
|
| 54 |
-
setSelectedKeyId(configs[0].id);
|
| 55 |
-
}
|
| 56 |
-
}, [configs, selectedKeyId]);
|
| 57 |
-
|
| 58 |
-
const selectedConfig = configs.find((c) => c.id === selectedKeyId);
|
| 59 |
-
const [useFreeCredits, setUseFreeCredits] = useState(false);
|
| 60 |
-
|
| 61 |
-
const myCredit = useQuery(
|
| 62 |
-
trpc.admin.getMyCredit.queryOptions(),
|
| 63 |
-
);
|
| 64 |
-
const hasFreeCredits = myCredit.data?.freeCreditsEnabled === true;
|
| 65 |
-
const tokenBalance = myCredit.data?.tokenBalance ?? 0;
|
| 66 |
-
|
| 67 |
-
const {
|
| 68 |
-
activeCount,
|
| 69 |
-
completedResults,
|
| 70 |
-
isGenerating,
|
| 71 |
-
error,
|
| 72 |
-
addJob,
|
| 73 |
-
removeJob,
|
| 74 |
-
resetAll,
|
| 75 |
-
setError,
|
| 76 |
-
} = useGenerationJobs();
|
| 77 |
-
|
| 78 |
-
const [activeTabIdx, setActiveTabIdx] = useState(0);
|
| 79 |
-
const prevResultsLengthRef = useRef(0);
|
| 80 |
-
|
| 81 |
-
// Auto-scroll to results when they appear
|
| 82 |
-
const resultsRef = useRef<HTMLDivElement>(null);
|
| 83 |
-
useEffect(() => {
|
| 84 |
-
if (completedResults.length > 0 && resultsRef.current) {
|
| 85 |
-
resultsRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
| 86 |
-
}
|
| 87 |
-
}, [completedResults.length]);
|
| 88 |
-
|
| 89 |
-
useEffect(() => {
|
| 90 |
-
const prev = prevResultsLengthRef.current;
|
| 91 |
-
prevResultsLengthRef.current = completedResults.length;
|
| 92 |
-
|
| 93 |
-
if (completedResults.length === 0) {
|
| 94 |
-
setActiveTabIdx(0);
|
| 95 |
-
return;
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
if (completedResults.length > prev) {
|
| 99 |
-
setActiveTabIdx(completedResults.length - 1);
|
| 100 |
-
return;
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
if (activeTabIdx >= completedResults.length) {
|
| 104 |
-
setActiveTabIdx(completedResults.length - 1);
|
| 105 |
-
}
|
| 106 |
-
}, [completedResults.length]);
|
| 107 |
-
|
| 108 |
-
const [examType, setExamType] = useState("IELTS");
|
| 109 |
-
const [selectedSections, setSelectedSections] = useState<string[]>(["READING"]);
|
| 110 |
-
const [selectedFormats, setSelectedFormats] = useState<string[]>(["multiple_choice"]);
|
| 111 |
-
const [difficulty, setDifficulty] = useState(2);
|
| 112 |
-
const [selectedTopics, setSelectedTopics] = useState<string[]>(["Science & Tech"]);
|
| 113 |
-
const [questionCount, setQuestionCount] = useState(5);
|
| 114 |
-
const [weaknessAlign, setWeaknessAlign] = useState(75);
|
| 115 |
-
const [mode, setMode] = useState<"quick" | "agentic">("quick");
|
| 116 |
-
|
| 117 |
-
const isReadingAndWriting = selectedSections.includes("READING") && selectedSections.includes("WRITING");
|
| 118 |
-
|
| 119 |
-
useEffect(() => {
|
| 120 |
-
setSelectedFormats((prev) => {
|
| 121 |
-
const valid = prev.filter((f) =>
|
| 122 |
-
FORMATS.find((fmt) => fmt.id === f)?.allowedExams.includes(examType),
|
| 123 |
-
);
|
| 124 |
-
if (valid.length === 0) {
|
| 125 |
-
return ["multiple_choice"];
|
| 126 |
-
}
|
| 127 |
-
return valid;
|
| 128 |
-
});
|
| 129 |
-
}, [examType]);
|
| 130 |
-
|
| 131 |
-
useEffect(() => {
|
| 132 |
-
if (isReadingAndWriting) {
|
| 133 |
-
if (questionCount < 20) setQuestionCount(20);
|
| 134 |
-
if (mode === "quick") setMode("agentic");
|
| 135 |
-
}
|
| 136 |
-
}, [isReadingAndWriting]);
|
| 137 |
-
|
| 138 |
-
const generate = useMutation({
|
| 139 |
-
...trpc.ai.generate.mutationOptions(),
|
| 140 |
-
onSuccess: (data) => {
|
| 141 |
-
addJob(data.jobId);
|
| 142 |
-
setError(null);
|
| 143 |
-
},
|
| 144 |
-
onError: (err) => {
|
| 145 |
-
setError(err.message);
|
| 146 |
-
},
|
| 147 |
-
});
|
| 148 |
-
|
| 149 |
-
const toggleSection = (id: string) => {
|
| 150 |
-
setSelectedSections((prev) => {
|
| 151 |
-
if (prev.includes(id)) {
|
| 152 |
-
if (prev.length === 1) return prev;
|
| 153 |
-
return prev.filter((s) => s !== id);
|
| 154 |
-
}
|
| 155 |
-
return [...prev, id];
|
| 156 |
-
});
|
| 157 |
-
};
|
| 158 |
-
|
| 159 |
-
const toggleFormat = (id: string) => {
|
| 160 |
-
setSelectedFormats((prev) =>
|
| 161 |
-
prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id],
|
| 162 |
-
);
|
| 163 |
-
};
|
| 164 |
-
|
| 165 |
-
const toggleTopic = (topic: string) => {
|
| 166 |
-
setSelectedTopics((prev) =>
|
| 167 |
-
prev.includes(topic) ? prev.filter((t) => t !== topic) : [...prev, topic],
|
| 168 |
-
);
|
| 169 |
-
};
|
| 170 |
-
|
| 171 |
-
const handleGenerate = () => {
|
| 172 |
-
if (!useFreeCredits && (!hasConfigs || !selectedConfig)) {
|
| 173 |
-
setError("API key belum dikonfigurasi. Tambahkan di Settings atau gunakan kredit gratis.");
|
| 174 |
-
return;
|
| 175 |
-
}
|
| 176 |
-
if (selectedSections.length === 0) {
|
| 177 |
-
setError("Pilih minimal 1 section.");
|
| 178 |
-
return;
|
| 179 |
-
}
|
| 180 |
-
if (selectedFormats.length === 0) {
|
| 181 |
-
setError("Pilih minimal 1 format soal.");
|
| 182 |
-
return;
|
| 183 |
-
}
|
| 184 |
-
|
| 185 |
-
const apiKeyConfig = useFreeCredits
|
| 186 |
-
? undefined
|
| 187 |
-
: {
|
| 188 |
-
baseUrl: selectedConfig!.baseUrl,
|
| 189 |
-
apiKey: selectedConfig!.apiKey,
|
| 190 |
-
model: selectedConfig!.modelName,
|
| 191 |
-
maxTokens: selectedConfig!.maxTokens ?? 16384,
|
| 192 |
-
};
|
| 193 |
-
|
| 194 |
-
generate.mutate({
|
| 195 |
-
examType: examType as any,
|
| 196 |
-
section: selectedSections[0] as any,
|
| 197 |
-
selectedSections: selectedSections as any,
|
| 198 |
-
formats: selectedFormats as any,
|
| 199 |
-
difficulty: difficulty + 1,
|
| 200 |
-
topics: selectedTopics,
|
| 201 |
-
questionCount,
|
| 202 |
-
mode,
|
| 203 |
-
apiKeyConfig,
|
| 204 |
-
} as any);
|
| 205 |
-
};
|
| 206 |
-
|
| 207 |
-
const sectionSplits = (() => {
|
| 208 |
-
if (mode !== "agentic" || questionCount < 20 || selectedSections.length <= 1) return null;
|
| 209 |
-
const base = Math.floor(questionCount / selectedSections.length);
|
| 210 |
-
const rem = questionCount % selectedSections.length;
|
| 211 |
-
return selectedSections.map((s, i) => ({ section: s, count: base + (i < rem ? 1 : 0) }));
|
| 212 |
-
})();
|
| 213 |
-
|
| 214 |
-
const activeResult = completedResults[activeTabIdx] ?? null;
|
| 215 |
-
|
| 216 |
-
return (
|
| 217 |
-
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
|
| 218 |
-
{/* Header */}
|
| 219 |
-
<section className="flex flex-col gap-2 relative mb-10">
|
| 220 |
-
<div className="absolute -left-8 -top-8 w-64 h-64 ai-glow pointer-events-none opacity-50" />
|
| 221 |
-
<h1 className="text-4xl md:text-5xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 222 |
-
AI Exam Generator
|
| 223 |
-
</h1>
|
| 224 |
-
<p className="text-lg text-[var(--warm-charcoal)] max-w-2xl leading-relaxed">
|
| 225 |
-
Generate soal latihan dengan AI. Pilih exam, section, format, dan topik — sisanya AI yang kerjakan.
|
| 226 |
-
</p>
|
| 227 |
-
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--warm-charcoal)]">
|
| 228 |
-
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] font-medium">
|
| 229 |
-
<MaterialIcon name="looks_one" className="text-sm" />
|
| 230 |
-
Pilih exam & section
|
| 231 |
-
</span>
|
| 232 |
-
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] font-medium">
|
| 233 |
-
<MaterialIcon name="looks_two" className="text-sm" />
|
| 234 |
-
Atur jumlah & format
|
| 235 |
-
</span>
|
| 236 |
-
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--lemon-400)]/30 text-[var(--lemon-800)] font-medium">
|
| 237 |
-
<MaterialIcon name="looks_3" className="text-sm" />
|
| 238 |
-
Generate & simpan
|
| 239 |
-
</span>
|
| 240 |
-
<span className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--clay-black)]/10 text-[var(--clay-black)] font-medium">
|
| 241 |
-
<MaterialIcon name="looks_4" className="text-sm" />
|
| 242 |
-
Buat paket dari Bank Soal
|
| 243 |
-
</span>
|
| 244 |
-
</div>
|
| 245 |
-
</section>
|
| 246 |
-
|
| 247 |
-
{!hasConfigs && !useFreeCredits && !hasFreeCredits && (
|
| 248 |
-
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--badge-blue-bg)] text-[var(--badge-blue-text)] text-sm flex items-center gap-3 border-2 border-[var(--badge-blue-bg)]">
|
| 249 |
-
<MaterialIcon name="warning" />
|
| 250 |
-
<span>API key belum dikonfigurasi.</span>
|
| 251 |
-
<Link to="/settings" className="font-semibold underline">
|
| 252 |
-
Tambahkan di Settings →
|
| 253 |
-
</Link>
|
| 254 |
-
</div>
|
| 255 |
-
)}
|
| 256 |
-
|
| 257 |
-
{!hasConfigs && !useFreeCredits && hasFreeCredits && (
|
| 258 |
-
<div className="mb-8 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 text-[var(--matcha-800)] text-sm flex items-center gap-3 border-2 border-[var(--matcha-400)]">
|
| 259 |
-
<MaterialIcon name="tips_and_updates" />
|
| 260 |
-
<span>Belum ada API key. Kamu bisa pakai Free Credits!</span>
|
| 261 |
-
<button onClick={() => setUseFreeCredits(true)} className="font-semibold underline">
|
| 262 |
-
Gunakan Free Credits →
|
| 263 |
-
</button>
|
| 264 |
-
</div>
|
| 265 |
-
)}
|
| 266 |
-
|
| 267 |
-
<div className="mb-8 p-5 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
|
| 268 |
-
<div className="flex items-center justify-between mb-3">
|
| 269 |
-
<label className="text-sm font-medium text-[var(--clay-black)]">Generation Mode</label>
|
| 270 |
-
</div>
|
| 271 |
-
<div className="flex items-center gap-4">
|
| 272 |
-
<button
|
| 273 |
-
onClick={() => { setUseFreeCredits(false); }}
|
| 274 |
-
className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
|
| 275 |
-
!useFreeCredits
|
| 276 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)]"
|
| 277 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
|
| 278 |
-
}`}
|
| 279 |
-
>
|
| 280 |
-
<MaterialIcon name="vpn_key" className="text-sm" />
|
| 281 |
-
BYOK
|
| 282 |
-
</button>
|
| 283 |
-
{hasFreeCredits && (
|
| 284 |
-
<button
|
| 285 |
-
onClick={() => { setUseFreeCredits(true); }}
|
| 286 |
-
className={`flex items-center gap-2 px-4 py-2.5 rounded-[var(--radius-lg)] text-sm font-medium transition-all ${
|
| 287 |
-
useFreeCredits
|
| 288 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)]"
|
| 289 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-border)]"
|
| 290 |
-
}`}
|
| 291 |
-
>
|
| 292 |
-
<MaterialIcon name="stars" className="text-sm" />
|
| 293 |
-
Free Credits
|
| 294 |
-
</button>
|
| 295 |
-
)}
|
| 296 |
-
{useFreeCredits && (
|
| 297 |
-
<Link to="/settings" className="text-xs text-[var(--matcha-600)] underline ml-2">
|
| 298 |
-
Atur BYOK di Settings
|
| 299 |
-
</Link>
|
| 300 |
-
)}
|
| 301 |
-
</div>
|
| 302 |
-
|
| 303 |
-
{useFreeCredits && myCredit.data && (
|
| 304 |
-
<div className="mt-4 pt-4 border-t border-[var(--oat-border)] space-y-2">
|
| 305 |
-
<div className="flex items-center justify-between">
|
| 306 |
-
<span className="text-sm text-[var(--warm-charcoal)]">Token kamu</span>
|
| 307 |
-
<span className={`text-lg font-headline font-bold ${tokenBalance > 0 ? "text-[var(--clay-black)]" : "text-[var(--clay-red)]"}`}>
|
| 308 |
-
{tokenBalance.toLocaleString()}
|
| 309 |
-
</span>
|
| 310 |
-
</div>
|
| 311 |
-
{tokenBalance > 0 && (
|
| 312 |
-
<div className="w-full h-2 bg-[var(--oat-border)] rounded-full overflow-hidden">
|
| 313 |
-
<div
|
| 314 |
-
className="h-full bg-[var(--matcha-500)] rounded-full transition-all"
|
| 315 |
-
style={{ width: `${Math.min(100, (tokenBalance / 50000) * 100)}%` }}
|
| 316 |
-
/>
|
| 317 |
-
</div>
|
| 318 |
-
)}
|
| 319 |
-
{myCredit.data.cooldownRemaining > 0 && (
|
| 320 |
-
<p className="flex items-center gap-1.5 text-xs text-[var(--sunbeam-800)] bg-[var(--sunbeam-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 321 |
-
<MaterialIcon name="schedule" className="text-base leading-none shrink-0" />
|
| 322 |
-
<span>
|
| 323 |
-
Cooldown: {myCredit.data.cooldownRemaining} hari lagi untuk auto-refill.
|
| 324 |
-
</span>
|
| 325 |
-
</p>
|
| 326 |
-
)}
|
| 327 |
-
{tokenBalance <= 0 && myCredit.data.cooldownRemaining === 0 && (
|
| 328 |
-
<p className="text-xs text-[var(--matcha-700)] bg-[var(--matcha-300)]/30 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 329 |
-
Token habis. Auto-refill tersedia saat kamu generate.
|
| 330 |
-
</p>
|
| 331 |
-
)}
|
| 332 |
-
{tokenBalance <= 0 && myCredit.data.cooldownRemaining > 0 && (
|
| 333 |
-
<p className="text-xs text-[var(--clay-red)]/80 bg-[var(--clay-red)]/5 px-3 py-1.5 rounded-[var(--radius-md)]">
|
| 334 |
-
Token habis & dalam cooldown. Gunakan BYOK atau tunggu {myCredit.data.cooldownRemaining} hari.
|
| 335 |
-
</p>
|
| 336 |
-
)}
|
| 337 |
-
</div>
|
| 338 |
-
)}
|
| 339 |
-
|
| 340 |
-
{!useFreeCredits && hasConfigs && (
|
| 341 |
-
<div className="mt-4 pt-4 border-t border-[var(--oat-border)]">
|
| 342 |
-
<label className="text-sm font-medium text-[var(--clay-black)] mb-2 block">Provider / API Key</label>
|
| 343 |
-
<div className="flex gap-3">
|
| 344 |
-
<Select value={selectedKeyId} onValueChange={(v) => v && setSelectedKeyId(v)}>
|
| 345 |
-
<SelectTrigger className="flex-1 h-11">
|
| 346 |
-
<SelectValue>
|
| 347 |
-
{selectedConfig ? `${selectedConfig.name} · ${selectedConfig.modelName}` : "Pilih provider..."}
|
| 348 |
-
</SelectValue>
|
| 349 |
-
</SelectTrigger>
|
| 350 |
-
<SelectContent>
|
| 351 |
-
{configs.map((c) => (
|
| 352 |
-
<SelectItem key={c.id} value={c.id}>
|
| 353 |
-
{c.name} · {c.modelName}
|
| 354 |
-
</SelectItem>
|
| 355 |
-
))}
|
| 356 |
-
</SelectContent>
|
| 357 |
-
</Select>
|
| 358 |
-
<Link to="/settings">
|
| 359 |
-
<Button variant="outline" size="xl" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover">
|
| 360 |
-
<MaterialIcon name="settings" className="mr-1" />
|
| 361 |
-
Kelola
|
| 362 |
-
</Button>
|
| 363 |
-
</Link>
|
| 364 |
-
</div>
|
| 365 |
-
</div>
|
| 366 |
-
)}
|
| 367 |
-
|
| 368 |
-
{!useFreeCredits && !hasConfigs && !hasFreeCredits && (
|
| 369 |
-
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 370 |
-
Tambahkan API key di Settings dahulu.
|
| 371 |
-
</p>
|
| 372 |
-
)}
|
| 373 |
-
|
| 374 |
-
{!useFreeCredits && !hasConfigs && hasFreeCredits && (
|
| 375 |
-
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 376 |
-
Belum ada API key?{" "}
|
| 377 |
-
<button onClick={() => setUseFreeCredits(true)} className="text-[var(--matcha-600)] underline">
|
| 378 |
-
Gunakan kredit gratis
|
| 379 |
-
</button>
|
| 380 |
-
{" "}atau tambah di Settings.
|
| 381 |
-
</p>
|
| 382 |
-
)}
|
| 383 |
-
|
| 384 |
-
{useFreeCredits && !hasFreeCredits && (
|
| 385 |
-
<p className="mt-3 text-xs text-[var(--warm-charcoal)]">
|
| 386 |
-
Free credits sedang dinonaktifkan oleh admin.
|
| 387 |
-
</p>
|
| 388 |
-
)}
|
| 389 |
-
</div>
|
| 390 |
-
|
| 391 |
-
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 items-start">
|
| 392 |
-
{/* Configuration Panel */}
|
| 393 |
-
<div className="lg:col-span-8 flex flex-col gap-10">
|
| 394 |
-
|
| 395 |
-
{/* Exam Type */}
|
| 396 |
-
<div data-tour="generate-exam-type" className="flex flex-col gap-4">
|
| 397 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Jenis Ujian</label>
|
| 398 |
-
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
| 399 |
-
{EXAM_TYPES.map((t) => (
|
| 400 |
-
<button
|
| 401 |
-
key={t.id}
|
| 402 |
-
onClick={() => setExamType(t.id)}
|
| 403 |
-
className={`flex items-center gap-3 py-4 px-4 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[56px] ${
|
| 404 |
-
examType === t.id
|
| 405 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
|
| 406 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
|
| 407 |
-
}`}
|
| 408 |
-
>
|
| 409 |
-
<span className={`fi fi-${t.code} w-6 h-4 rounded-sm shadow-sm shrink-0`} />
|
| 410 |
-
{t.name}
|
| 411 |
-
</button>
|
| 412 |
-
))}
|
| 413 |
-
</div>
|
| 414 |
-
</div>
|
| 415 |
-
|
| 416 |
-
{/* Section Selection — Multi-select */}
|
| 417 |
-
<div data-tour="generate-section" className="flex flex-col gap-4">
|
| 418 |
-
<div className="flex items-center justify-between">
|
| 419 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Section</label>
|
| 420 |
-
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 421 |
-
{selectedSections.length} dipilih
|
| 422 |
-
</span>
|
| 423 |
-
</div>
|
| 424 |
-
<div className="flex flex-wrap gap-3">
|
| 425 |
-
{SECTIONS.map((s) => {
|
| 426 |
-
const isSelected = selectedSections.includes(s.id);
|
| 427 |
-
return (
|
| 428 |
-
<button
|
| 429 |
-
key={s.id}
|
| 430 |
-
onClick={() => toggleSection(s.id)}
|
| 431 |
-
className={`flex items-center gap-2.5 px-5 py-3 rounded-[var(--radius-lg)] border-2 transition-all text-sm font-semibold clay-hover min-h-[52px] ${
|
| 432 |
-
isSelected
|
| 433 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow border-[var(--clay-black)]"
|
| 434 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-[var(--oat-border)]"
|
| 435 |
-
}`}
|
| 436 |
-
>
|
| 437 |
-
<MaterialIcon
|
| 438 |
-
name={isSelected ? "check_circle" : s.icon}
|
| 439 |
-
className={`text-base shrink-0 ${isSelected ? "text-[var(--matcha-400)]" : ""}`}
|
| 440 |
-
/>
|
| 441 |
-
{s.name}
|
| 442 |
-
</button>
|
| 443 |
-
);
|
| 444 |
-
})}
|
| 445 |
-
</div>
|
| 446 |
-
{selectedSections.length > 1 && (
|
| 447 |
-
<p className="text-xs text-[var(--matcha-800)] bg-[var(--matcha-300)]/30 px-3 py-2 rounded-[var(--radius-md)]">
|
| 448 |
-
<MaterialIcon name="tips_and_updates" className="text-xs mr-1 inline" />
|
| 449 |
-
Kamu memilih {selectedSections.length} section. Mode Agentic dengan ≥20 soal akan otomatis membagi soal ke section yang dipilih.
|
| 450 |
-
</p>
|
| 451 |
-
)}
|
| 452 |
-
</div>
|
| 453 |
-
|
| 454 |
-
{/* Question Count */}
|
| 455 |
-
<div data-tour="generate-count" className="flex flex-col gap-4">
|
| 456 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">
|
| 457 |
-
Jumlah Soal
|
| 458 |
-
<span className="ml-2 text-sm font-normal text-[var(--warm-charcoal)]">{questionCount} soal</span>
|
| 459 |
-
</label>
|
| 460 |
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
| 461 |
-
{QUESTION_COUNT_PRESETS.map((p) => {
|
| 462 |
-
const isDisabled = isReadingAndWriting && (p.value === 5 || p.value === 10);
|
| 463 |
-
return (
|
| 464 |
-
<button
|
| 465 |
-
key={p.value}
|
| 466 |
-
onClick={() => setQuestionCount(p.value)}
|
| 467 |
-
disabled={isDisabled}
|
| 468 |
-
className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover flex flex-col items-center gap-1 min-h-[72px] ${
|
| 469 |
-
isDisabled
|
| 470 |
-
? "bg-[var(--oat-light)] text-[var(--warm-silver)] cursor-not-allowed opacity-50 border-2 border-[var(--oat-border)]"
|
| 471 |
-
: questionCount === p.value
|
| 472 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 473 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
|
| 474 |
-
}`}
|
| 475 |
-
>
|
| 476 |
-
<span>{p.label}</span>
|
| 477 |
-
<span className={`text-xs ${questionCount === p.value ? "text-[var(--pure-white)]/70" : "text-[var(--warm-charcoal)]/70"}`}>{p.desc}</span>
|
| 478 |
-
</button>
|
| 479 |
-
);
|
| 480 |
-
})}
|
| 481 |
-
</div>
|
| 482 |
-
<div className="flex items-center gap-3 mt-1">
|
| 483 |
-
<span className="text-xs font-medium text-[var(--warm-charcoal)] whitespace-nowrap">Custom:</span>
|
| 484 |
-
<input
|
| 485 |
-
type="range"
|
| 486 |
-
min={isReadingAndWriting ? 20 : 1}
|
| 487 |
-
max={40}
|
| 488 |
-
value={questionCount}
|
| 489 |
-
onChange={(e) => setQuestionCount(Number(e.target.value))}
|
| 490 |
-
className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
|
| 491 |
-
/>
|
| 492 |
-
<span className="text-xs font-bold text-[var(--clay-black)] w-6 text-right">{questionCount}</span>
|
| 493 |
-
</div>
|
| 494 |
-
|
| 495 |
-
{/* Auto Multi-Section Preview */}
|
| 496 |
-
{sectionSplits && (
|
| 497 |
-
<div className="mt-2 p-4 rounded-[var(--radius-lg)] bg-[var(--matcha-300)]/30 border border-[var(--matcha-400)]">
|
| 498 |
-
<div className="flex items-center gap-2 mb-2 text-[var(--matcha-800)] font-semibold text-sm">
|
| 499 |
-
<MaterialIcon name="auto_awesome" className="text-xs" />
|
| 500 |
-
Auto Multi-Section
|
| 501 |
-
</div>
|
| 502 |
-
<p className="text-[var(--matcha-800)]/80 text-xs mb-3">
|
| 503 |
-
Mode Agentic dengan {questionCount} soal akan dibagi ke {sectionSplits.length} section:
|
| 504 |
-
</p>
|
| 505 |
-
<div className="flex flex-wrap gap-2">
|
| 506 |
-
{sectionSplits.map((s) => {
|
| 507 |
-
const sec = SECTIONS.find((sec) => sec.id === s.section);
|
| 508 |
-
return (
|
| 509 |
-
<span
|
| 510 |
-
key={s.section}
|
| 511 |
-
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--pure-white)] text-[var(--matcha-800)] text-xs font-medium border border-[var(--matcha-400)]"
|
| 512 |
-
>
|
| 513 |
-
<MaterialIcon name={sec?.icon ?? "menu_book"} className="text-[10px]" />
|
| 514 |
-
{sec?.name ?? s.section}: {s.count} soal
|
| 515 |
-
</span>
|
| 516 |
-
);
|
| 517 |
-
})}
|
| 518 |
-
</div>
|
| 519 |
-
</div>
|
| 520 |
-
)}
|
| 521 |
-
</div>
|
| 522 |
-
|
| 523 |
-
{/* Difficulty */}
|
| 524 |
-
<div data-tour="generate-difficulty" className="flex flex-col gap-4">
|
| 525 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Tingkat Kesulitan</label>
|
| 526 |
-
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
| 527 |
-
{DIFFICULTIES.map((d, i) => (
|
| 528 |
-
<button
|
| 529 |
-
key={d}
|
| 530 |
-
onClick={() => setDifficulty(i)}
|
| 531 |
-
className={`py-4 px-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all clay-hover min-h-[56px] flex flex-col items-center ${
|
| 532 |
-
difficulty === i
|
| 533 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 534 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] border-2 border-[var(--oat-border)]"
|
| 535 |
-
}`}
|
| 536 |
-
>
|
| 537 |
-
<span>{getDifficultyLabel(examType, i + 1)}</span>
|
| 538 |
-
<span className={`text-[10px] mt-0.5 ${difficulty === i ? "text-white/60" : "text-[var(--warm-silver)]"}`}>{d}</span>
|
| 539 |
-
</button>
|
| 540 |
-
))}
|
| 541 |
-
</div>
|
| 542 |
-
</div>
|
| 543 |
-
|
| 544 |
-
{/* Format Selection */}
|
| 545 |
-
<div data-tour="generate-format" className="flex flex-col gap-4">
|
| 546 |
-
<div className="flex items-center justify-between">
|
| 547 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Format Soal</label>
|
| 548 |
-
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 549 |
-
{selectedFormats.length} dipilih
|
| 550 |
-
</span>
|
| 551 |
-
</div>
|
| 552 |
-
<div className="flex flex-wrap gap-2">
|
| 553 |
-
{FORMATS.filter((f) => f.allowedExams.includes(examType)).map((f) => (
|
| 554 |
-
<button
|
| 555 |
-
key={f.id}
|
| 556 |
-
onClick={() => toggleFormat(f.id)}
|
| 557 |
-
className={`px-4 py-2.5 rounded-full text-sm font-medium flex items-center gap-2 cursor-pointer transition-all clay-hover min-h-[40px] ${
|
| 558 |
-
selectedFormats.includes(f.id)
|
| 559 |
-
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 560 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
|
| 561 |
-
}`}
|
| 562 |
-
>
|
| 563 |
-
{f.name}
|
| 564 |
-
{selectedFormats.includes(f.id) && (
|
| 565 |
-
<MaterialIcon name="close" className="text-sm" />
|
| 566 |
-
)}
|
| 567 |
-
</button>
|
| 568 |
-
))}
|
| 569 |
-
</div>
|
| 570 |
-
</div>
|
| 571 |
-
|
| 572 |
-
{/* Topic Focus */}
|
| 573 |
-
<div data-tour="generate-topic" className="flex flex-col gap-4">
|
| 574 |
-
<div className="flex items-center justify-between">
|
| 575 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Topik</label>
|
| 576 |
-
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 577 |
-
{selectedTopics.length} dipilih
|
| 578 |
-
</span>
|
| 579 |
-
</div>
|
| 580 |
-
<div className="flex flex-wrap gap-2">
|
| 581 |
-
{selectedTopics.map((topic) => (
|
| 582 |
-
<span
|
| 583 |
-
key={topic}
|
| 584 |
-
onClick={() => toggleTopic(topic)}
|
| 585 |
-
className="px-4 py-2 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] font-medium flex items-center gap-2 cursor-pointer transition-all hover:brightness-95 clay-hover min-h-[40px]"
|
| 586 |
-
>
|
| 587 |
-
{topic} <MaterialIcon name="close" className="text-sm" />
|
| 588 |
-
</span>
|
| 589 |
-
))}
|
| 590 |
-
{TOPICS.filter((t) => !selectedTopics.includes(t)).map((topic) => (
|
| 591 |
-
<button
|
| 592 |
-
key={topic}
|
| 593 |
-
onClick={() => toggleTopic(topic)}
|
| 594 |
-
className="px-4 py-2 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] font-medium hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)] transition-all clay-hover min-h-[40px]"
|
| 595 |
-
>
|
| 596 |
-
{topic}
|
| 597 |
-
</button>
|
| 598 |
-
))}
|
| 599 |
-
</div>
|
| 600 |
-
</div>
|
| 601 |
-
|
| 602 |
-
{/* Weakness Alignment */}
|
| 603 |
-
<div data-tour="generate-weakness" className="flex flex-col gap-4">
|
| 604 |
-
<div className="flex justify-between items-end">
|
| 605 |
-
<label className="font-headline text-xl font-bold text-[var(--clay-black)]">Fokus Latihan</label>
|
| 606 |
-
<span className="text-sm font-medium text-[var(--matcha-800)] bg-[var(--matcha-300)] px-3 py-1 rounded-full">
|
| 607 |
-
Intelligent Focus
|
| 608 |
-
</span>
|
| 609 |
-
</div>
|
| 610 |
-
<div className="relative py-4">
|
| 611 |
-
<input
|
| 612 |
-
type="range"
|
| 613 |
-
min="0"
|
| 614 |
-
max="100"
|
| 615 |
-
value={weaknessAlign}
|
| 616 |
-
onChange={(e) => setWeaknessAlign(Number(e.target.value))}
|
| 617 |
-
className="w-full h-2 bg-[var(--warm-silver)] rounded-full appearance-none cursor-pointer accent-[var(--clay-black)]"
|
| 618 |
-
/>
|
| 619 |
-
<div className="flex justify-between mt-4 text-xs font-label uppercase tracking-widest text-[var(--warm-charcoal)]">
|
| 620 |
-
<span>Soal Seimbang</span>
|
| 621 |
-
<span>Fokus Kelemahan</span>
|
| 622 |
-
</div>
|
| 623 |
-
</div>
|
| 624 |
-
</div>
|
| 625 |
-
</div>
|
| 626 |
-
|
| 627 |
-
{/* Live Preview Card */}
|
| 628 |
-
<div data-tour="generate-blueprint" className="lg:col-span-4">
|
| 629 |
-
<TestBlueprintCard
|
| 630 |
-
examType={examType}
|
| 631 |
-
selectedSections={selectedSections}
|
| 632 |
-
selectedFormats={selectedFormats}
|
| 633 |
-
questionCount={questionCount}
|
| 634 |
-
weaknessAlign={weaknessAlign}
|
| 635 |
-
mode={mode}
|
| 636 |
-
setMode={setMode}
|
| 637 |
-
activeCount={activeCount}
|
| 638 |
-
maxParallel={MAX_PARALLEL}
|
| 639 |
-
generatePending={generate.isPending}
|
| 640 |
-
hasKey={hasConfigs || useFreeCredits}
|
| 641 |
-
error={error}
|
| 642 |
-
onGenerate={handleGenerate}
|
| 643 |
-
onDismissError={() => setError(null)}
|
| 644 |
-
disableQuick={isReadingAndWriting}
|
| 645 |
-
/>
|
| 646 |
-
</div>
|
| 647 |
-
</div>
|
| 648 |
-
|
| 649 |
-
{/* Results with Tabs */}
|
| 650 |
-
<div ref={resultsRef}>
|
| 651 |
-
{completedResults.length > 0 && (
|
| 652 |
-
<div className="mt-12">
|
| 653 |
-
<div className="flex items-center justify-between mb-4">
|
| 654 |
-
<h2 className="text-2xl font-headline font-bold text-[var(--clay-black)]">
|
| 655 |
-
Hasil Generate
|
| 656 |
-
</h2>
|
| 657 |
-
<Button
|
| 658 |
-
variant="ghost"
|
| 659 |
-
size="sm"
|
| 660 |
-
onClick={resetAll}
|
| 661 |
-
className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)]"
|
| 662 |
-
>
|
| 663 |
-
<MaterialIcon name="delete_sweep" className="text-sm mr-1" />
|
| 664 |
-
Bersihkan
|
| 665 |
-
</Button>
|
| 666 |
-
</div>
|
| 667 |
-
|
| 668 |
-
{/* Tab Bar */}
|
| 669 |
-
<div className="flex gap-1 mb-6 border-b border-[var(--oat-border)] overflow-x-auto">
|
| 670 |
-
{completedResults.map((res, idx) => {
|
| 671 |
-
const questions = res.result?.questions ?? [];
|
| 672 |
-
const isActive = idx === activeTabIdx;
|
| 673 |
-
return (
|
| 674 |
-
<button
|
| 675 |
-
key={res.jobId}
|
| 676 |
-
onClick={() => setActiveTabIdx(idx)}
|
| 677 |
-
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold rounded-t-lg transition-all whitespace-nowrap border-b-2 min-h-[44px] ${
|
| 678 |
-
isActive
|
| 679 |
-
? "bg-[var(--pure-white)] text-[var(--clay-black)] border-[var(--clay-black)]"
|
| 680 |
-
: "text-[var(--warm-charcoal)] border-transparent hover:text-[var(--clay-black)] hover:bg-[var(--oat-light)]"
|
| 681 |
-
}`}
|
| 682 |
-
>
|
| 683 |
-
<MaterialIcon
|
| 684 |
-
name={questions.length > 0 ? "check_circle" : "sync"}
|
| 685 |
-
className={`text-sm ${isActive ? "text-[var(--matcha-400)]" : ""} ${questions.length === 0 && !isActive ? "animate-spin" : ""}`}
|
| 686 |
-
/>
|
| 687 |
-
<span>
|
| 688 |
-
{res.mode === "agentic" ? "Agentic" : "Quick"}
|
| 689 |
-
</span>
|
| 690 |
-
<span className="text-xs text-[var(--warm-charcoal)]">
|
| 691 |
-
{questions.length} soal
|
| 692 |
-
</span>
|
| 693 |
-
<button
|
| 694 |
-
onClick={(e) => {
|
| 695 |
-
e.stopPropagation();
|
| 696 |
-
removeJob(res.jobId);
|
| 697 |
-
}}
|
| 698 |
-
className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--pomegranate-400)]/10 text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors"
|
| 699 |
-
>
|
| 700 |
-
<MaterialIcon name="close" className="text-xs" />
|
| 701 |
-
</button>
|
| 702 |
-
</button>
|
| 703 |
-
);
|
| 704 |
-
})}
|
| 705 |
-
</div>
|
| 706 |
-
|
| 707 |
-
{/* Active Tab Content */}
|
| 708 |
-
{activeResult && (
|
| 709 |
-
<ResultSection
|
| 710 |
-
result={activeResult.result}
|
| 711 |
-
generatedPackageId={activeResult.generatedPackageId}
|
| 712 |
-
onClear={resetAll}
|
| 713 |
-
/>
|
| 714 |
-
)}
|
| 715 |
-
</div>
|
| 716 |
-
)}
|
| 717 |
-
</div>
|
| 718 |
-
|
| 719 |
-
<PageTour
|
| 720 |
-
storageKey={GENERATE_TOUR_KEY}
|
| 721 |
-
autoDelay={600}
|
| 722 |
-
steps={generatePageSteps}
|
| 723 |
-
/>
|
| 724 |
-
<TourHelpButton storageKey={GENERATE_TOUR_KEY} />
|
| 725 |
-
</div>
|
| 726 |
-
);
|
| 727 |
-
}
|
| 728 |
-
|
| 729 |
-
// ── Generate page tour ──
|
| 730 |
-
const GENERATE_TOUR_KEY = "labas-page-tour-generate";
|
| 731 |
-
const generatePageSteps: Step[] = [
|
| 732 |
-
{
|
| 733 |
-
target: "[data-tour='generate-exam-type']",
|
| 734 |
-
title: "Jenis Ujian",
|
| 735 |
-
content: "Pilih jenis ujian yang ingin kamu latih. Tersedia IELTS, TOEFL, JLPT, HSK, Goethe, TOPIK (Korea), TOAFL (Arab), dan DELE (Spanyol).",
|
| 736 |
-
spotlightPadding: 8,
|
| 737 |
-
},
|
| 738 |
-
{
|
| 739 |
-
target: "[data-tour='generate-section']",
|
| 740 |
-
title: "Section",
|
| 741 |
-
content: "Pilih section yang ingin digenerate. Bisa pilih lebih dari satu. Mode Agentic dengan ≥20 soal otomatis membagi soal ke setiap section.",
|
| 742 |
-
spotlightPadding: 8,
|
| 743 |
-
},
|
| 744 |
-
{
|
| 745 |
-
target: "[data-tour='generate-count']",
|
| 746 |
-
title: "Jumlah Soal",
|
| 747 |
-
content: "Atur jumlah soal yang ingin digenerate via preset atau slider. Maksimal 40 soal per generate.",
|
| 748 |
-
spotlightPadding: 8,
|
| 749 |
-
},
|
| 750 |
-
{
|
| 751 |
-
target: "[data-tour='generate-difficulty']",
|
| 752 |
-
title: "Tingkat Kesulitan",
|
| 753 |
-
content: "Pilih tingkat kesulitan. Label menyesuaikan dengan jenis ujian yang dipilih (misal: N5-N1 untuk JLPT, Band 4.0-8.0 untuk IELTS).",
|
| 754 |
-
spotlightPadding: 8,
|
| 755 |
-
},
|
| 756 |
-
{
|
| 757 |
-
target: "[data-tour='generate-format']",
|
| 758 |
-
title: "Format Soal",
|
| 759 |
-
content: "Pilih format soal (multiple choice, true/false, dll). Format tersedia tergantung exam type yang dipilih.",
|
| 760 |
-
spotlightPadding: 8,
|
| 761 |
-
},
|
| 762 |
-
{
|
| 763 |
-
target: "[data-tour='generate-topic']",
|
| 764 |
-
title: "Topik",
|
| 765 |
-
content: "Pilih topik yang ingin difokuskan. Bisa pilih lebih dari satu topik.",
|
| 766 |
-
spotlightPadding: 8,
|
| 767 |
-
},
|
| 768 |
-
{
|
| 769 |
-
target: "[data-tour='generate-weakness']",
|
| 770 |
-
title: "Intelligent Focus",
|
| 771 |
-
content: "Atur fokus pada kelemahan kamu. AI akan menarget area yang perlu ditingkatkan berdasarkan riwayat jawaban.",
|
| 772 |
-
spotlightPadding: 8,
|
| 773 |
-
},
|
| 774 |
-
{
|
| 775 |
-
target: "[data-tour='generate-blueprint']",
|
| 776 |
-
title: "Test Blueprint & Generate",
|
| 777 |
-
content: "Ringkasan konfigurasi kamu. Pilih mode Quick (cepat) atau Agentic (multi-tahap). Klik 'Generate & Launch' untuk memulai!",
|
| 778 |
-
spotlightPadding: 8,
|
| 779 |
-
},
|
| 780 |
-
];
|
|
|
|
| 1 |
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
|
|
|
|
|
| 2 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export const Route = createFileRoute("/generate")({
|
|
|
|
| 5 |
beforeLoad: async () => {
|
| 6 |
const session = await authClient.getSession();
|
| 7 |
if (!session.data) {
|
|
|
|
| 10 |
return { session };
|
| 11 |
},
|
| 12 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/web/src/routes/landing.lazy.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { LandingPage } from "@/components/routes/LandingPage";
|
| 3 |
+
|
| 4 |
+
export const Route = createLazyFileRoute("/landing")({
|
| 5 |
+
component: LandingPage,
|
| 6 |
+
});
|
apps/web/src/routes/landing.tsx
CHANGED
|
@@ -1,12 +1,7 @@
|
|
| 1 |
-
import { createFileRoute,
|
| 2 |
-
import { Button } from "@labas/ui/components/button";
|
| 3 |
-
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
-
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
-
import { useState } from "react";
|
| 6 |
import { authClient } from "@/lib/auth-client";
|
| 7 |
|
| 8 |
export const Route = createFileRoute("/landing")({
|
| 9 |
-
component: LandingPage,
|
| 10 |
beforeLoad: async () => {
|
| 11 |
try {
|
| 12 |
const session = await authClient.getSession();
|
|
@@ -18,279 +13,3 @@ export const Route = createFileRoute("/landing")({
|
|
| 18 |
}
|
| 19 |
},
|
| 20 |
});
|
| 21 |
-
|
| 22 |
-
function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
|
| 23 |
-
const [isOpen, setIsOpen] = useState(false);
|
| 24 |
-
|
| 25 |
-
return (
|
| 26 |
-
<div className={`border-b-2 py-6 ${isDark ? 'border-[var(--ube-300)]/30' : 'border-[var(--oat-border)]'} last:border-b-0`}>
|
| 27 |
-
<button
|
| 28 |
-
type="button"
|
| 29 |
-
className="flex w-full items-center justify-between text-left focus:outline-none group"
|
| 30 |
-
onClick={() => setIsOpen(!isOpen)}
|
| 31 |
-
aria-expanded={isOpen}
|
| 32 |
-
>
|
| 33 |
-
<span className={`font-headline font-semibold text-2xl tracking-[-0.64px] transition-colors ${isDark ? 'text-[var(--pure-white)] group-hover:text-[var(--ube-300)]' : 'text-[var(--clay-black)] group-hover:text-[var(--matcha-700)]'}`}>
|
| 34 |
-
{question}
|
| 35 |
-
</span>
|
| 36 |
-
<div className={`w-10 h-10 rounded-full border-2 flex items-center justify-center transition-transform duration-300 ${isOpen ? "rotate-180" : ""} ${isDark ? 'border-[var(--ube-300)]/50 text-[var(--pure-white)] group-hover:bg-[var(--ube-300)]/20' : 'border-[var(--oat-border)] text-[var(--clay-black)] group-hover:bg-[var(--oat-light)]'}`}>
|
| 37 |
-
<MaterialIcon
|
| 38 |
-
name="expand_more"
|
| 39 |
-
className="text-2xl"
|
| 40 |
-
/>
|
| 41 |
-
</div>
|
| 42 |
-
</button>
|
| 43 |
-
<div
|
| 44 |
-
className={`overflow-hidden transition-all duration-300 ease-in-out ${isOpen ? "max-h-96 mt-4 opacity-100" : "max-h-0 opacity-0"}`}
|
| 45 |
-
>
|
| 46 |
-
<p className={`text-lg leading-relaxed pr-12 ${isDark ? 'text-[var(--ube-300)]' : 'text-[var(--warm-charcoal)]'}`}>
|
| 47 |
-
{answer}
|
| 48 |
-
</p>
|
| 49 |
-
</div>
|
| 50 |
-
</div>
|
| 51 |
-
);
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
function LandingPage() {
|
| 55 |
-
return (
|
| 56 |
-
<div className="min-h-screen bg-[var(--warm-cream)] flex flex-col font-sans selection:bg-[var(--matcha-300)] selection:text-[var(--clay-black)]">
|
| 57 |
-
{/* Navbar */}
|
| 58 |
-
<nav className="w-full px-6 py-4 md:px-12 lg:px-16 flex items-center justify-between max-w-7xl mx-auto z-50 sticky top-0 bg-[var(--warm-cream)] border-b-2 border-[var(--oat-border)]">
|
| 59 |
-
<div className="flex items-center gap-3">
|
| 60 |
-
<img src="/logo.png" alt="Labas Logo" className="h-10 w-auto object-contain" />
|
| 61 |
-
<span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
|
| 62 |
-
</div>
|
| 63 |
-
<div className="flex items-center gap-4">
|
| 64 |
-
<Link to="/login">
|
| 65 |
-
<Button variant="ghost" className="text-[var(--clay-black)] font-semibold hover:bg-[var(--oat-light)] rounded-[12px] text-lg px-6 h-12">
|
| 66 |
-
Masuk
|
| 67 |
-
</Button>
|
| 68 |
-
</Link>
|
| 69 |
-
<Link to="/login">
|
| 70 |
-
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--dark-charcoal)] rounded-[24px] h-12 px-8 font-semibold text-lg clay-hover clay-shadow">
|
| 71 |
-
Mulai Gratis
|
| 72 |
-
</Button>
|
| 73 |
-
</Link>
|
| 74 |
-
</div>
|
| 75 |
-
</nav>
|
| 76 |
-
|
| 77 |
-
<main className="flex-1 flex flex-col items-center overflow-x-hidden">
|
| 78 |
-
{/* HUGE HERO SECTION */}
|
| 79 |
-
<section className="w-full px-6 md:px-12 lg:px-16 pt-16 pb-20 md:pt-24 md:pb-32 flex flex-col xl:flex-row items-center justify-between gap-12 lg:gap-16 relative max-w-[1440px] mx-auto">
|
| 80 |
-
<div className="flex-1 text-center xl:text-left space-y-8 z-10 w-full max-w-[800px] xl:max-w-none">
|
| 81 |
-
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--matcha-300)] border-2 border-[var(--matcha-800)] text-[var(--clay-black)] uppercase-label shadow-sm mx-auto xl:mx-0 transform -rotate-2">
|
| 82 |
-
<MaterialIcon name="auto_awesome" className="text-sm" />
|
| 83 |
-
<span>Didukung oleh AI Generative</span>
|
| 84 |
-
</div>
|
| 85 |
-
|
| 86 |
-
<h1 className="text-[50px] md:text-[70px] lg:text-[85px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] lg:tracking-[-3.2px] leading-[1.0] lg:leading-[0.95] drop-shadow-sm">
|
| 87 |
-
Platform Latihan Bahasa Cerdas.
|
| 88 |
-
</h1>
|
| 89 |
-
|
| 90 |
-
<p className="text-xl md:text-2xl text-[var(--warm-charcoal)] max-w-2xl mx-auto xl:mx-0 leading-relaxed">
|
| 91 |
-
Persiapkan dirimu untuk ujian bahasa asing dengan latihan soal interaktif, mock test realistis, dan AI Generator super cepat.
|
| 92 |
-
</p>
|
| 93 |
-
|
| 94 |
-
<div className="flex flex-col sm:flex-row items-center gap-6 justify-center xl:justify-start pt-6">
|
| 95 |
-
<Link to="/login" className="w-full sm:w-auto">
|
| 96 |
-
<Button className="w-full sm:w-auto bg-[var(--pure-white)] text-[var(--clay-black)] rounded-[24px] h-[64px] px-10 text-xl font-semibold border-2 border-[var(--oat-border)] clay-shadow clay-hover">
|
| 97 |
-
Mulai Latihan Sekarang
|
| 98 |
-
</Button>
|
| 99 |
-
</Link>
|
| 100 |
-
</div>
|
| 101 |
-
</div>
|
| 102 |
-
|
| 103 |
-
{/* MASSIVE HERO IMAGE WITH FLOATING ASSETS - SIDE BY SIDE ON DESKTOP */}
|
| 104 |
-
<div className="flex-1 w-full relative group mt-8 xl:mt-0 flex justify-center xl:justify-end">
|
| 105 |
-
<div className="relative w-full max-w-[650px] lg:max-w-[750px]">
|
| 106 |
-
<img
|
| 107 |
-
src="/hero_img.png"
|
| 108 |
-
alt="Labas Dashboard Preview"
|
| 109 |
-
className="w-full h-auto object-contain transform transition-transform duration-700 group-hover:scale-105 group-hover:-rotate-1 drop-shadow-[0_20px_50px_rgba(0,0,0,0.15)] relative z-10"
|
| 110 |
-
/>
|
| 111 |
-
{/* Variatif Floating Elements */}
|
| 112 |
-
<div className="absolute -top-8 -left-8 md:-top-12 md:-left-12 w-32 md:w-40 h-auto z-20 hidden sm:block">
|
| 113 |
-
<img src="/generateai.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
|
| 114 |
-
</div>
|
| 115 |
-
<div className="absolute -bottom-8 -right-8 md:-bottom-12 md:-right-12 w-40 md:w-48 h-auto z-20 hidden sm:block">
|
| 116 |
-
<img src="/mocktest.png" alt="floating element" className="w-full h-auto drop-shadow-2xl" />
|
| 117 |
-
</div>
|
| 118 |
-
<div className="absolute top-[40%] -left-16 md:-left-24 w-24 md:w-32 h-auto z-0 hidden lg:block opacity-80 blur-[1px]">
|
| 119 |
-
<img src="/progress.png" alt="floating element" className="w-full h-auto drop-shadow-xl" />
|
| 120 |
-
</div>
|
| 121 |
-
</div>
|
| 122 |
-
</div>
|
| 123 |
-
</section>
|
| 124 |
-
|
| 125 |
-
{/* Feature Cards Section */}
|
| 126 |
-
<section id="features" className="w-full bg-[var(--pure-white)] py-32 border-y-2 border-dashed border-[var(--oat-border)]">
|
| 127 |
-
<div className="max-w-7xl mx-auto px-6 md:px-12 lg:px-16">
|
| 128 |
-
<div className="text-center max-w-4xl mx-auto mb-24 space-y-6">
|
| 129 |
-
<h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--clay-black)] tracking-[-2.4px] leading-tight">
|
| 130 |
-
Fitur Unggulan Labas
|
| 131 |
-
</h2>
|
| 132 |
-
<p className="text-2xl text-[var(--warm-charcoal)]">
|
| 133 |
-
Desain yang elegan, namun kokoh untuk mempercepat kesiapan Anda menghadapi ujian.
|
| 134 |
-
</p>
|
| 135 |
-
</div>
|
| 136 |
-
|
| 137 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 138 |
-
{/* Feature 1 */}
|
| 139 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 140 |
-
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 141 |
-
<img src="/generateai.png" alt="AI Generator" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 142 |
-
</div>
|
| 143 |
-
<CardContent className="p-6">
|
| 144 |
-
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 145 |
-
AI Generator
|
| 146 |
-
</h3>
|
| 147 |
-
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 148 |
-
Hasilkan soal latihan baru menggunakan AI. Tentukan topik, level kesulitan, dan format dalam hitungan detik.
|
| 149 |
-
</p>
|
| 150 |
-
</CardContent>
|
| 151 |
-
</Card>
|
| 152 |
-
|
| 153 |
-
{/* Feature 2 */}
|
| 154 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 155 |
-
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 156 |
-
<img src="/latihansoal.png" alt="Latihan Soal" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 157 |
-
</div>
|
| 158 |
-
<CardContent className="p-6">
|
| 159 |
-
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 160 |
-
Latihan Terfokus
|
| 161 |
-
</h3>
|
| 162 |
-
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 163 |
-
Akses ribuan soal latihan dari bank soal. Buat paket latihan Anda sendiri dan fokus pada area kelemahan.
|
| 164 |
-
</p>
|
| 165 |
-
</CardContent>
|
| 166 |
-
</Card>
|
| 167 |
-
|
| 168 |
-
{/* Feature 3 */}
|
| 169 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 170 |
-
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 171 |
-
<img src="/mocktest.png" alt="Mock Test" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 172 |
-
</div>
|
| 173 |
-
<CardContent className="p-6">
|
| 174 |
-
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 175 |
-
Simulasi Ujian
|
| 176 |
-
</h3>
|
| 177 |
-
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 178 |
-
Simulasikan suasana ujian sesungguhnya dengan batas waktu, antarmuka imersif, dan penilaian instan.
|
| 179 |
-
</p>
|
| 180 |
-
</CardContent>
|
| 181 |
-
</Card>
|
| 182 |
-
|
| 183 |
-
{/* Feature 4 */}
|
| 184 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 185 |
-
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 186 |
-
<img src="/progress.png" alt="Progress Tracking" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 187 |
-
</div>
|
| 188 |
-
<CardContent className="p-6">
|
| 189 |
-
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 190 |
-
Analitik Progres
|
| 191 |
-
</h3>
|
| 192 |
-
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 193 |
-
Lacak perkembangan nilai Anda dari waktu ke waktu. Analisis mendalam untuk setiap bagian tes bahasa.
|
| 194 |
-
</p>
|
| 195 |
-
</CardContent>
|
| 196 |
-
</Card>
|
| 197 |
-
|
| 198 |
-
{/* Feature 5 */}
|
| 199 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 200 |
-
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 201 |
-
<img src="/vocabulary.png" alt="Vocabulary" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 202 |
-
</div>
|
| 203 |
-
<CardContent className="p-6">
|
| 204 |
-
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 205 |
-
Kosakata
|
| 206 |
-
</h3>
|
| 207 |
-
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 208 |
-
Perkaya kosakata Anda dengan metode cerdas dan pengulangan berkala yang dioptimalkan.
|
| 209 |
-
</p>
|
| 210 |
-
</CardContent>
|
| 211 |
-
</Card>
|
| 212 |
-
|
| 213 |
-
{/* Feature 6 */}
|
| 214 |
-
<Card className="clay-shadow clay-hover bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[24px] overflow-hidden group p-4">
|
| 215 |
-
<div className="h-56 w-full flex items-center justify-center p-2">
|
| 216 |
-
<img src="/diskusi.png" alt="Diskusi" className="h-full w-auto object-contain transform group-hover:scale-110 group-hover:-rotate-3 transition-transform duration-500 drop-shadow-lg" />
|
| 217 |
-
</div>
|
| 218 |
-
<CardContent className="p-6">
|
| 219 |
-
<h3 className="font-headline text-[32px] font-semibold text-[var(--clay-black)] tracking-[-0.64px] mb-3 leading-tight">
|
| 220 |
-
Forum Diskusi
|
| 221 |
-
</h3>
|
| 222 |
-
<p className="text-[var(--warm-charcoal)] text-lg leading-relaxed">
|
| 223 |
-
Diskusikan soal-soal sulit dengan komunitas pembelajar lainnya dan dapatkan penjelasan ahli.
|
| 224 |
-
</p>
|
| 225 |
-
</CardContent>
|
| 226 |
-
</Card>
|
| 227 |
-
</div>
|
| 228 |
-
</div>
|
| 229 |
-
</section>
|
| 230 |
-
|
| 231 |
-
{/* SWATCH ROOM 1: Ube FAQ Section */}
|
| 232 |
-
<section className="w-full bg-[var(--ube-800)] py-32 rounded-t-[40px] mt-[-40px] z-10 relative shadow-[0_-10px_40px_rgba(0,0,0,0.1)] border-t-2 border-[var(--oat-border)]/20">
|
| 233 |
-
<div className="max-w-4xl mx-auto px-6 md:px-12 lg:px-16">
|
| 234 |
-
<div className="text-center mb-16">
|
| 235 |
-
<h2 className="text-[50px] md:text-[60px] font-headline font-semibold text-[var(--pure-white)] tracking-[-2.4px] mb-6 leading-tight drop-shadow-sm">
|
| 236 |
-
Pertanyaan Umum
|
| 237 |
-
</h2>
|
| 238 |
-
<p className="text-[var(--ube-300)] text-2xl max-w-2xl mx-auto">
|
| 239 |
-
Temukan jawaban cepat untuk pertanyaan seputar Labas.
|
| 240 |
-
</p>
|
| 241 |
-
</div>
|
| 242 |
-
|
| 243 |
-
<div className="bg-[var(--ube-900)]/40 backdrop-blur-sm border-2 border-[var(--ube-300)]/30 rounded-[32px] p-8 md:p-12 shadow-2xl">
|
| 244 |
-
<FaqItem
|
| 245 |
-
isDark
|
| 246 |
-
question="Apa itu Labas?"
|
| 247 |
-
answer="Labas adalah platform latihan ujian bahasa berbasis AI yang dirancang untuk membantu Anda berlatih dan menguasai bahasa asing melalui simulasi, bank soal interaktif, dan analitik performa."
|
| 248 |
-
/>
|
| 249 |
-
<FaqItem
|
| 250 |
-
isDark
|
| 251 |
-
question="Bagaimana cara kerja AI Generator?"
|
| 252 |
-
answer="Fitur AI Generator memungkinkan Anda membuat paket soal baru berdasarkan konteks atau topik tertentu. Anda cukup memasukkan teks acuan, dan AI Agent kami akan memproduksi soal secara otomatis."
|
| 253 |
-
/>
|
| 254 |
-
<FaqItem
|
| 255 |
-
isDark
|
| 256 |
-
question="Apakah Labas sepenuhnya gratis?"
|
| 257 |
-
answer="Platform Labas dapat digunakan secara gratis untuk fitur dasar. Untuk fitur generasi soal berbasis AI, kami menggunakan model Bring-Your-Own-Key (BYOK). Anda cukup memasukkan API Key OpenAI Anda."
|
| 258 |
-
/>
|
| 259 |
-
<FaqItem
|
| 260 |
-
isDark
|
| 261 |
-
question="Bahasa apa saja yang didukung oleh Labas?"
|
| 262 |
-
answer="Saat ini Labas mendukung latihan untuk berbagai ujian profisiensi bahasa populer seperti Bahasa Inggris (TOEFL, IELTS, TOEIC), Jepang (JLPT), Korea (TOPIK), dan banyak lagi."
|
| 263 |
-
/>
|
| 264 |
-
</div>
|
| 265 |
-
</div>
|
| 266 |
-
</section>
|
| 267 |
-
|
| 268 |
-
{/* SWATCH ROOM 2: Matcha CTA Section */}
|
| 269 |
-
<section className="w-full bg-[var(--matcha-800)] py-40 px-6 rounded-t-[40px] mt-[-40px] z-20 relative shadow-[0_-10px_40px_rgba(0,0,0,0.2)] border-t-2 border-[var(--matcha-600)]">
|
| 270 |
-
<div className="max-w-4xl mx-auto text-center space-y-12">
|
| 271 |
-
<h2 className="text-[60px] md:text-[80px] font-headline font-semibold text-[var(--pure-white)] tracking-[-3.2px] leading-[0.95] drop-shadow-md">
|
| 272 |
-
Siap Meningkatkan Skor Anda?
|
| 273 |
-
</h2>
|
| 274 |
-
<p className="text-2xl text-[var(--matcha-300)] max-w-2xl mx-auto">
|
| 275 |
-
Bergabung sekarang dan rasakan perbedaan belajar dengan teknologi yang berpusat pada perkembangan Anda.
|
| 276 |
-
</p>
|
| 277 |
-
<div className="pt-8">
|
| 278 |
-
<Link to="/login" className="inline-block">
|
| 279 |
-
<Button className="bg-[var(--pure-white)] text-[var(--clay-black)] hover:bg-[var(--oat-light)] rounded-[24px] h-[80px] px-14 text-2xl font-bold clay-shadow clay-hover">
|
| 280 |
-
Daftar Sekarang - Gratis
|
| 281 |
-
</Button>
|
| 282 |
-
</Link>
|
| 283 |
-
</div>
|
| 284 |
-
</div>
|
| 285 |
-
</section>
|
| 286 |
-
</main>
|
| 287 |
-
|
| 288 |
-
{/* Footer */}
|
| 289 |
-
<footer className="w-full bg-[var(--pure-white)] py-12 border-t-2 border-[var(--oat-border)] text-center relative z-30">
|
| 290 |
-
<p className="text-[var(--warm-charcoal)] font-semibold text-lg">
|
| 291 |
-
© {new Date().getFullYear()} Labas. Didesain dengan penuh kehangatan.
|
| 292 |
-
</p>
|
| 293 |
-
</footer>
|
| 294 |
-
</div>
|
| 295 |
-
);
|
| 296 |
-
}
|
|
|
|
| 1 |
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { authClient } from "@/lib/auth-client";
|
| 3 |
|
| 4 |
export const Route = createFileRoute("/landing")({
|
|
|
|
| 5 |
beforeLoad: async () => {
|
| 6 |
try {
|
| 7 |
const session = await authClient.getSession();
|
|
|
|
| 13 |
}
|
| 14 |
},
|
| 15 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/web/src/routes/packages.lazy.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { PackagesComponent } from "@/components/routes/PackagesPage";
|
| 3 |
+
|
| 4 |
+
export const Route = createLazyFileRoute("/packages")({
|
| 5 |
+
component: PackagesComponent,
|
| 6 |
+
});
|
apps/web/src/routes/packages.tsx
CHANGED
|
@@ -1,31 +1,8 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { useQuery, useMutation } from "@tanstack/react-query";
|
| 3 |
-
import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
|
| 4 |
import { z } from "zod";
|
| 5 |
import { authClient } from "@/lib/auth-client";
|
| 6 |
-
import { trpc } from "@/utils/trpc";
|
| 7 |
-
import { Input } from "@labas/ui/components/input";
|
| 8 |
-
import { Button } from "@labas/ui/components/button";
|
| 9 |
-
import { Card, CardContent } from "@labas/ui/components/card";
|
| 10 |
-
import {
|
| 11 |
-
Select,
|
| 12 |
-
SelectContent,
|
| 13 |
-
SelectGroup,
|
| 14 |
-
SelectItem,
|
| 15 |
-
SelectTrigger,
|
| 16 |
-
SelectValue,
|
| 17 |
-
} from "@labas/ui/components/select";
|
| 18 |
-
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 19 |
-
import { GettingStartedCard } from "@/components/GettingStartedCard";
|
| 20 |
-
import { CalloutCard } from "@/components/bank/CalloutCard";
|
| 21 |
-
import { PageTour, TourHelpButton } from "@/components/TourGuide";
|
| 22 |
-
import type { Step } from "react-joyride";
|
| 23 |
-
import { toast } from "sonner";
|
| 24 |
-
import { getErrorMessage } from "@/lib/error-utils";
|
| 25 |
-
import { EXAM_TYPES } from "@/lib/exam-constants";
|
| 26 |
|
| 27 |
export const Route = createFileRoute("/packages")({
|
| 28 |
-
component: PackagesComponent,
|
| 29 |
validateSearch: z.object({
|
| 30 |
tab: z.enum(["all", "mine"]).optional(),
|
| 31 |
search: z.string().optional(),
|
|
@@ -41,552 +18,3 @@ export const Route = createFileRoute("/packages")({
|
|
| 41 |
return { session };
|
| 42 |
},
|
| 43 |
});
|
| 44 |
-
|
| 45 |
-
type Tab = "all" | "mine";
|
| 46 |
-
|
| 47 |
-
function PackagesComponent() {
|
| 48 |
-
const routerNavigate = useNavigate();
|
| 49 |
-
const search = Route.useSearch();
|
| 50 |
-
const navigate = Route.useNavigate();
|
| 51 |
-
const { data: session } = authClient.useSession();
|
| 52 |
-
const userId = session?.user.id;
|
| 53 |
-
|
| 54 |
-
const tab = search.tab ?? "all";
|
| 55 |
-
const searchText = search.search ?? "";
|
| 56 |
-
const examType = search.examType ?? "";
|
| 57 |
-
const page = search.page ?? 1;
|
| 58 |
-
const visibilityFilter = search.visibility ?? "all";
|
| 59 |
-
const limit = 12;
|
| 60 |
-
|
| 61 |
-
const allQuery = useQuery(
|
| 62 |
-
trpc.package.list.queryOptions(
|
| 63 |
-
{
|
| 64 |
-
isPublic: true,
|
| 65 |
-
examTypeId: examType || undefined,
|
| 66 |
-
search: searchText || undefined,
|
| 67 |
-
limit,
|
| 68 |
-
offset: (page - 1) * limit,
|
| 69 |
-
},
|
| 70 |
-
{ enabled: tab === "all" },
|
| 71 |
-
),
|
| 72 |
-
);
|
| 73 |
-
|
| 74 |
-
const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
|
| 75 |
-
? { isPublic: visibilityFilter === "public" }
|
| 76 |
-
: {};
|
| 77 |
-
|
| 78 |
-
const mineQuery = useQuery(
|
| 79 |
-
trpc.package.myPackages.queryOptions(
|
| 80 |
-
{
|
| 81 |
-
search: searchText || undefined,
|
| 82 |
-
examTypeId: examType || undefined,
|
| 83 |
-
limit,
|
| 84 |
-
offset: (page - 1) * limit,
|
| 85 |
-
...visibilityFilterParam,
|
| 86 |
-
},
|
| 87 |
-
{ enabled: tab === "mine" },
|
| 88 |
-
),
|
| 89 |
-
);
|
| 90 |
-
|
| 91 |
-
const query = tab === "all" ? allQuery : mineQuery;
|
| 92 |
-
const packages = query.data?.packages ?? [];
|
| 93 |
-
const total = query.data?.total ?? 0;
|
| 94 |
-
const totalPages = Math.ceil(total / limit);
|
| 95 |
-
|
| 96 |
-
const updateMutation = useMutation(
|
| 97 |
-
trpc.package.update.mutationOptions({
|
| 98 |
-
onSuccess: () => {
|
| 99 |
-
query.refetch();
|
| 100 |
-
},
|
| 101 |
-
}),
|
| 102 |
-
);
|
| 103 |
-
|
| 104 |
-
const togglePublic = (pkgId: string, current: boolean) => {
|
| 105 |
-
updateMutation.mutate({ id: pkgId, isPublic: !current });
|
| 106 |
-
};
|
| 107 |
-
|
| 108 |
-
const bulkPublish = useMutation(
|
| 109 |
-
trpc.package.bulkPublish.mutationOptions({
|
| 110 |
-
onSuccess: (data) => {
|
| 111 |
-
query.refetch();
|
| 112 |
-
setBulkMode(false);
|
| 113 |
-
setSelectedIds(new Set());
|
| 114 |
-
if (data.skipped > 0) {
|
| 115 |
-
toast.success(
|
| 116 |
-
`${data.updated} paket dipublikasikan, ${data.skipped} dilewati`,
|
| 117 |
-
{ description: "Beberapa paket bukan milikmu atau sudah tidak tersedia." },
|
| 118 |
-
);
|
| 119 |
-
} else {
|
| 120 |
-
toast.success(`${data.updated} paket berhasil dipublikasikan`);
|
| 121 |
-
}
|
| 122 |
-
},
|
| 123 |
-
onError: (err: unknown) => {
|
| 124 |
-
toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang paket.", { description: getErrorMessage(err) });
|
| 125 |
-
},
|
| 126 |
-
}),
|
| 127 |
-
);
|
| 128 |
-
|
| 129 |
-
// ── Bulk select ──
|
| 130 |
-
const [bulkMode, setBulkMode] = useState(false);
|
| 131 |
-
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
| 132 |
-
|
| 133 |
-
const toggleSelect = (id: string) => {
|
| 134 |
-
setSelectedIds((prev) => {
|
| 135 |
-
const next = new Set(prev);
|
| 136 |
-
if (next.has(id)) next.delete(id);
|
| 137 |
-
else next.add(id);
|
| 138 |
-
return next;
|
| 139 |
-
});
|
| 140 |
-
};
|
| 141 |
-
|
| 142 |
-
const clearSelection = () => setSelectedIds(new Set());
|
| 143 |
-
const selectAll = () => setSelectedIds(new Set(packages.map((p) => p.id)));
|
| 144 |
-
|
| 145 |
-
useEffect(() => {
|
| 146 |
-
setBulkMode(false);
|
| 147 |
-
setSelectedIds(new Set());
|
| 148 |
-
}, [tab, searchText, examType]);
|
| 149 |
-
|
| 150 |
-
const setTab = (newTab: Tab) => {
|
| 151 |
-
navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
|
| 152 |
-
};
|
| 153 |
-
|
| 154 |
-
// ── Private callout state ──
|
| 155 |
-
const [calloutDismissed, setCalloutDismissed] = useState(
|
| 156 |
-
typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
|
| 157 |
-
);
|
| 158 |
-
const privatePackages = packages.filter(
|
| 159 |
-
(p) => !p.isPublic && p.creatorUserId === userId,
|
| 160 |
-
);
|
| 161 |
-
|
| 162 |
-
const handleDismissCallout = () => {
|
| 163 |
-
localStorage.setItem("labas-packages-private-callout-dismissed", "true");
|
| 164 |
-
setCalloutDismissed(true);
|
| 165 |
-
};
|
| 166 |
-
|
| 167 |
-
const handlePublishAllPrivate = () => {
|
| 168 |
-
const ids = privatePackages.map((p) => p.id);
|
| 169 |
-
if (ids.length > 0) bulkPublish.mutate({ ids });
|
| 170 |
-
};
|
| 171 |
-
|
| 172 |
-
const setSearch = (value: string) => {
|
| 173 |
-
navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
|
| 174 |
-
};
|
| 175 |
-
|
| 176 |
-
const setExamType = (value: string) => {
|
| 177 |
-
navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
|
| 178 |
-
};
|
| 179 |
-
|
| 180 |
-
const setVisibility = (value: "all" | "private" | "public") => {
|
| 181 |
-
navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value, page: 1 }) });
|
| 182 |
-
};
|
| 183 |
-
|
| 184 |
-
const setPage = (newPage: number) => {
|
| 185 |
-
navigate({ search: (prev) => ({ ...prev, page: newPage }) });
|
| 186 |
-
};
|
| 187 |
-
|
| 188 |
-
return (
|
| 189 |
-
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-7xl mx-auto bg-[var(--warm-cream)]">
|
| 190 |
-
<section className="mb-8">
|
| 191 |
-
<div data-tour="packages-header" className="flex items-center justify-between">
|
| 192 |
-
<div>
|
| 193 |
-
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 194 |
-
Paket Soal
|
| 195 |
-
</h1>
|
| 196 |
-
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 197 |
-
Kumpulan paket latihan dari komunitas.
|
| 198 |
-
</p>
|
| 199 |
-
</div>
|
| 200 |
-
<Link to="/bank">
|
| 201 |
-
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] clay-hover rounded-[var(--radius-lg)] h-11">
|
| 202 |
-
<MaterialIcon name="add" />
|
| 203 |
-
<span className="ml-2 hidden sm:inline">Buat Paket</span>
|
| 204 |
-
</Button>
|
| 205 |
-
</Link>
|
| 206 |
-
</div>
|
| 207 |
-
</section>
|
| 208 |
-
|
| 209 |
-
{/* Getting Started Guide */}
|
| 210 |
-
<GettingStartedCard />
|
| 211 |
-
|
| 212 |
-
{/* Tabs */}
|
| 213 |
-
<div className="flex gap-2 mb-6">
|
| 214 |
-
<button
|
| 215 |
-
onClick={() => setTab("all")}
|
| 216 |
-
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 217 |
-
tab === "all"
|
| 218 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 219 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 220 |
-
}`}
|
| 221 |
-
>
|
| 222 |
-
Semua Paket
|
| 223 |
-
</button>
|
| 224 |
-
<button
|
| 225 |
-
onClick={() => setTab("mine")}
|
| 226 |
-
className={`px-4 py-2 rounded-[var(--radius-lg)] text-sm font-semibold transition-all ${
|
| 227 |
-
tab === "mine"
|
| 228 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 229 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 230 |
-
}`}
|
| 231 |
-
>
|
| 232 |
-
Paket Saya
|
| 233 |
-
</button>
|
| 234 |
-
</div>
|
| 235 |
-
|
| 236 |
-
{/* Filters */}
|
| 237 |
-
<div data-tour="packages-filters" className="flex flex-col md:flex-row gap-3 mb-8">
|
| 238 |
-
<div className="relative flex-1 max-w-md">
|
| 239 |
-
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 240 |
-
<Input
|
| 241 |
-
placeholder="Cari paket..."
|
| 242 |
-
value={searchText}
|
| 243 |
-
onChange={(e) => setSearch(e.target.value)}
|
| 244 |
-
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 245 |
-
/>
|
| 246 |
-
</div>
|
| 247 |
-
<Select
|
| 248 |
-
value={examType}
|
| 249 |
-
onValueChange={(v: string | null) => setExamType(v ?? "")}
|
| 250 |
-
>
|
| 251 |
-
<SelectTrigger className="w-36">
|
| 252 |
-
<SelectValue placeholder="Semua Ujian" />
|
| 253 |
-
</SelectTrigger>
|
| 254 |
-
<SelectContent>
|
| 255 |
-
<SelectGroup>
|
| 256 |
-
<SelectItem value="">Semua Ujian</SelectItem>
|
| 257 |
-
{EXAM_TYPES.map((t) => (
|
| 258 |
-
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
| 259 |
-
))}
|
| 260 |
-
</SelectGroup>
|
| 261 |
-
</SelectContent>
|
| 262 |
-
</Select>
|
| 263 |
-
{tab === "mine" && (
|
| 264 |
-
<div className="flex gap-2">
|
| 265 |
-
<VisChip active={visibilityFilter === "all"} onClick={() => setVisibility("all")}>
|
| 266 |
-
<MaterialIcon name="visibility" className="text-xs" />
|
| 267 |
-
Semua
|
| 268 |
-
</VisChip>
|
| 269 |
-
<VisChip active={visibilityFilter === "private"} onClick={() => setVisibility("private")}>
|
| 270 |
-
<MaterialIcon name="lock" className="text-xs" />
|
| 271 |
-
Privat
|
| 272 |
-
</VisChip>
|
| 273 |
-
<VisChip active={visibilityFilter === "public"} onClick={() => setVisibility("public")}>
|
| 274 |
-
<MaterialIcon name="public" className="text-xs" />
|
| 275 |
-
Publik
|
| 276 |
-
</VisChip>
|
| 277 |
-
</div>
|
| 278 |
-
)}
|
| 279 |
-
</div>
|
| 280 |
-
|
| 281 |
-
{/* Bulk toolbar */}
|
| 282 |
-
{tab === "mine" && (
|
| 283 |
-
<div className="flex items-center justify-between mb-4 p-3 rounded-[var(--radius-lg)] bg-[var(--oat-light)] border-2 border-[var(--oat-border)]">
|
| 284 |
-
{bulkMode ? (
|
| 285 |
-
<>
|
| 286 |
-
<div className="flex items-center gap-3">
|
| 287 |
-
<button
|
| 288 |
-
onClick={clearSelection}
|
| 289 |
-
className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center gap-1"
|
| 290 |
-
>
|
| 291 |
-
<MaterialIcon name="close" className="text-xs" />
|
| 292 |
-
Batalkan ({selectedIds.size})
|
| 293 |
-
</button>
|
| 294 |
-
<button
|
| 295 |
-
onClick={selectAll}
|
| 296 |
-
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
|
| 297 |
-
>
|
| 298 |
-
<MaterialIcon name="select_all" className="text-xs" />
|
| 299 |
-
Pilih Semua
|
| 300 |
-
</button>
|
| 301 |
-
</div>
|
| 302 |
-
<div className="flex items-center gap-2">
|
| 303 |
-
<Button
|
| 304 |
-
size="lg"
|
| 305 |
-
disabled={selectedIds.size === 0 || bulkPublish.isPending}
|
| 306 |
-
onClick={() => bulkPublish.mutate({ ids: Array.from(selectedIds) })}
|
| 307 |
-
className="rounded-[var(--radius-md)] bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)]"
|
| 308 |
-
>
|
| 309 |
-
<MaterialIcon name="public" className="text-xs mr-1" />
|
| 310 |
-
{bulkPublish.isPending ? "Mempublikasikan..." : `Jadikan Publik (${selectedIds.size})`}
|
| 311 |
-
</Button>
|
| 312 |
-
<button
|
| 313 |
-
onClick={() => { setBulkMode(false); setSelectedIds(new Set()); }}
|
| 314 |
-
className="text-xs font-semibold text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
|
| 315 |
-
>
|
| 316 |
-
Selesai
|
| 317 |
-
</button>
|
| 318 |
-
</div>
|
| 319 |
-
</>
|
| 320 |
-
) : (
|
| 321 |
-
<>
|
| 322 |
-
<span className="text-sm text-[var(--warm-charcoal)]">{packages.length} paket</span>
|
| 323 |
-
<button
|
| 324 |
-
onClick={() => setBulkMode(true)}
|
| 325 |
-
className="text-xs font-semibold text-[var(--matcha-600)] hover:text-[var(--matcha-800)] transition-colors flex items-center gap-1"
|
| 326 |
-
>
|
| 327 |
-
<MaterialIcon name="select_all" className="text-sm" />
|
| 328 |
-
Pilih Banyak
|
| 329 |
-
</button>
|
| 330 |
-
</>
|
| 331 |
-
)}
|
| 332 |
-
</div>
|
| 333 |
-
)}
|
| 334 |
-
|
| 335 |
-
{/* Private package callout */}
|
| 336 |
-
{tab === "mine" && privatePackages.length > 0 && !calloutDismissed && (
|
| 337 |
-
<div className="mb-6">
|
| 338 |
-
<CalloutCard
|
| 339 |
-
privateCount={privatePackages.length}
|
| 340 |
-
onPublishAll={handlePublishAllPrivate}
|
| 341 |
-
onDismiss={handleDismissCallout}
|
| 342 |
-
/>
|
| 343 |
-
</div>
|
| 344 |
-
)}
|
| 345 |
-
|
| 346 |
-
{/* Results */}
|
| 347 |
-
{query.isLoading ? (
|
| 348 |
-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 349 |
-
{Array.from({ length: 6 }).map((_, i) => (
|
| 350 |
-
<Card key={i} className="h-48 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 351 |
-
))}
|
| 352 |
-
</div>
|
| 353 |
-
) : packages.length === 0 ? (
|
| 354 |
-
<div className="text-center py-16">
|
| 355 |
-
<MaterialIcon name="folder_open" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 356 |
-
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Tidak ada paket ditemukan</p>
|
| 357 |
-
<p className="text-sm text-[var(--warm-silver)] mt-1 mb-6">
|
| 358 |
-
{tab === "mine"
|
| 359 |
-
? "Belum ada paket yang Anda buat. Buat paket dari Bank Soal."
|
| 360 |
-
: "Belum ada paket publik. Buat paket soal pertama Anda"}
|
| 361 |
-
</p>
|
| 362 |
-
<div className="flex items-center justify-center gap-3">
|
| 363 |
-
<Link to="/generate">
|
| 364 |
-
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
|
| 365 |
-
<MaterialIcon name="auto_awesome" className="mr-2" />
|
| 366 |
-
Generate Soal
|
| 367 |
-
</Button>
|
| 368 |
-
</Link>
|
| 369 |
-
<Link to="/bank">
|
| 370 |
-
<Button variant="outline" className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]">
|
| 371 |
-
<MaterialIcon name="add" className="mr-2" />
|
| 372 |
-
Buat Paket
|
| 373 |
-
</Button>
|
| 374 |
-
</Link>
|
| 375 |
-
</div>
|
| 376 |
-
</div>
|
| 377 |
-
) : (
|
| 378 |
-
<>
|
| 379 |
-
<div data-tour="packages-list" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 380 |
-
{packages.map((pkg) => {
|
| 381 |
-
const isOwner = pkg.creatorUserId === userId;
|
| 382 |
-
const isSelected = selectedIds.has(pkg.id);
|
| 383 |
-
return (
|
| 384 |
-
<Card
|
| 385 |
-
key={pkg.id}
|
| 386 |
-
className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
|
| 387 |
-
bulkMode && isSelected
|
| 388 |
-
? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
|
| 389 |
-
: isOwner && !pkg.isPublic && !bulkMode
|
| 390 |
-
? "border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4"
|
| 391 |
-
: "border-[var(--oat-border)]"
|
| 392 |
-
}`}
|
| 393 |
-
>
|
| 394 |
-
<CardContent className="p-5 flex flex-col h-full">
|
| 395 |
-
<div
|
| 396 |
-
className="block flex-1 cursor-pointer"
|
| 397 |
-
onClick={bulkMode ? () => toggleSelect(pkg.id) : undefined}
|
| 398 |
-
>
|
| 399 |
-
<Link
|
| 400 |
-
to="/package/$id"
|
| 401 |
-
params={{ id: pkg.id }}
|
| 402 |
-
className={bulkMode ? "pointer-events-none" : ""}
|
| 403 |
-
>
|
| 404 |
-
<div className="flex items-start justify-between mb-3">
|
| 405 |
-
<div className="flex gap-2 flex-wrap">
|
| 406 |
-
{bulkMode && (
|
| 407 |
-
<span className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 408 |
-
isSelected
|
| 409 |
-
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
|
| 410 |
-
: "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
|
| 411 |
-
}`}>
|
| 412 |
-
<MaterialIcon name={isSelected ? "check_circle" : "radio_button_unchecked"} className="text-xs" />
|
| 413 |
-
{isSelected ? "Terpilih" : "Pilih"}
|
| 414 |
-
</span>
|
| 415 |
-
)}
|
| 416 |
-
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold leading-none whitespace-nowrap">
|
| 417 |
-
{pkg.examTypeName}
|
| 418 |
-
</span>
|
| 419 |
-
{isOwner && !bulkMode && (
|
| 420 |
-
<span
|
| 421 |
-
className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
|
| 422 |
-
pkg.isPublic
|
| 423 |
-
? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
|
| 424 |
-
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
|
| 425 |
-
}`}
|
| 426 |
-
>
|
| 427 |
-
{!pkg.isPublic && <MaterialIcon name="lock" className="text-[10px]" />}
|
| 428 |
-
{pkg.isPublic ? "Publik" : "Privat"}
|
| 429 |
-
</span>
|
| 430 |
-
)}
|
| 431 |
-
</div>
|
| 432 |
-
{pkg.avgRating && (
|
| 433 |
-
<div className="flex items-center gap-1 text-[var(--lemon-700)]">
|
| 434 |
-
<MaterialIcon name="star" className="text-sm" />
|
| 435 |
-
<span className="text-xs font-bold">{pkg.avgRating}</span>
|
| 436 |
-
</div>
|
| 437 |
-
)}
|
| 438 |
-
</div>
|
| 439 |
-
|
| 440 |
-
<h3 className="font-headline text-lg font-bold text-[var(--clay-black)] mb-2 line-clamp-2">
|
| 441 |
-
{pkg.title}
|
| 442 |
-
</h3>
|
| 443 |
-
|
| 444 |
-
{pkg.description && (
|
| 445 |
-
<p className="text-sm text-[var(--warm-charcoal)] line-clamp-2 mb-4">
|
| 446 |
-
{pkg.description}
|
| 447 |
-
</p>
|
| 448 |
-
)}
|
| 449 |
-
</Link>
|
| 450 |
-
|
| 451 |
-
<div className="flex items-center justify-between mt-auto pt-3 border-t border-[var(--oat-border)]">
|
| 452 |
-
<div className="flex gap-3 text-xs text-[var(--warm-charcoal)]">
|
| 453 |
-
<span className="flex items-center gap-1">
|
| 454 |
-
<MaterialIcon name="quiz" className="text-xs" />
|
| 455 |
-
{pkg.totalQuestions}
|
| 456 |
-
</span>
|
| 457 |
-
<span className="flex items-center gap-1">
|
| 458 |
-
<MaterialIcon name="folder" className="text-xs" />
|
| 459 |
-
{pkg.totalSections}
|
| 460 |
-
</span>
|
| 461 |
-
{pkg.estimatedDurationMin && (
|
| 462 |
-
<span className="flex items-center gap-1">
|
| 463 |
-
<MaterialIcon name="timer" className="text-xs" />
|
| 464 |
-
{pkg.estimatedDurationMin}m
|
| 465 |
-
</span>
|
| 466 |
-
)}
|
| 467 |
-
</div>
|
| 468 |
-
<span className="text-xs text-[var(--warm-silver)]">
|
| 469 |
-
{pkg.usageCount}x digunakan
|
| 470 |
-
</span>
|
| 471 |
-
</div>
|
| 472 |
-
</div>
|
| 473 |
-
|
| 474 |
-
{/* Owner actions */}
|
| 475 |
-
{isOwner && !bulkMode && (
|
| 476 |
-
<div className="mt-3 pt-3 border-t border-[var(--oat-border)] flex items-center justify-between">
|
| 477 |
-
<button
|
| 478 |
-
onClick={() => togglePublic(pkg.id, pkg.isPublic)}
|
| 479 |
-
disabled={updateMutation.isPending}
|
| 480 |
-
title={pkg.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
|
| 481 |
-
className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors flex items-center gap-1 cursor-pointer ${
|
| 482 |
-
pkg.isPublic
|
| 483 |
-
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 484 |
-
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
|
| 485 |
-
}`}
|
| 486 |
-
>
|
| 487 |
-
{!pkg.isPublic && <MaterialIcon name="lock" className="text-xs" />}
|
| 488 |
-
{pkg.isPublic ? "Publik" : "Privat"}
|
| 489 |
-
</button>
|
| 490 |
-
{pkg.isPublic && (
|
| 491 |
-
<button
|
| 492 |
-
onClick={() => {
|
| 493 |
-
const url = `${window.location.origin}/package/${pkg.id}`;
|
| 494 |
-
navigator.clipboard.writeText(url);
|
| 495 |
-
toast.success("Link paket disalin!");
|
| 496 |
-
}}
|
| 497 |
-
className="text-xs text-[var(--matcha-600)] hover:bg-[var(--matcha-300)]/20 px-3 py-1.5 rounded-full transition-colors flex items-center gap-1"
|
| 498 |
-
>
|
| 499 |
-
<MaterialIcon name="share" className="text-xs" />
|
| 500 |
-
Bagikan
|
| 501 |
-
</button>
|
| 502 |
-
)}
|
| 503 |
-
</div>
|
| 504 |
-
)}
|
| 505 |
-
|
| 506 |
-
{!bulkMode && (
|
| 507 |
-
<div className="mt-3 pt-3 border-t border-[var(--oat-border)]">
|
| 508 |
-
<Button
|
| 509 |
-
className="w-full bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-800)] clay-hover rounded-[var(--radius-lg)]"
|
| 510 |
-
onClick={() => routerNavigate({ to: '/package/$id/take', params: { id: pkg.id } })}
|
| 511 |
-
size="xl"
|
| 512 |
-
>
|
| 513 |
-
<MaterialIcon name="play_arrow" className="mr-2" />
|
| 514 |
-
Mulai Latihan
|
| 515 |
-
</Button>
|
| 516 |
-
</div>
|
| 517 |
-
)}
|
| 518 |
-
</CardContent>
|
| 519 |
-
</Card>
|
| 520 |
-
);
|
| 521 |
-
})}
|
| 522 |
-
</div>
|
| 523 |
-
|
| 524 |
-
{totalPages > 1 && (
|
| 525 |
-
<div className="flex items-center justify-center gap-2 mt-10">
|
| 526 |
-
<Button
|
| 527 |
-
variant="outline"
|
| 528 |
-
onClick={() => setPage(Math.max(1, page - 1))}
|
| 529 |
-
disabled={page <= 1}
|
| 530 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 531 |
-
>
|
| 532 |
-
<MaterialIcon name="chevron_left" />
|
| 533 |
-
</Button>
|
| 534 |
-
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 535 |
-
Halaman {page} dari {totalPages}
|
| 536 |
-
</span>
|
| 537 |
-
<Button
|
| 538 |
-
variant="outline"
|
| 539 |
-
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
| 540 |
-
disabled={page >= totalPages}
|
| 541 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 542 |
-
>
|
| 543 |
-
<MaterialIcon name="chevron_right" />
|
| 544 |
-
</Button>
|
| 545 |
-
</div>
|
| 546 |
-
)}
|
| 547 |
-
</>
|
| 548 |
-
)}
|
| 549 |
-
|
| 550 |
-
<PageTour storageKey={PACKAGES_TOUR_KEY} autoDelay={600} steps={packagesPageSteps} />
|
| 551 |
-
<TourHelpButton storageKey={PACKAGES_TOUR_KEY} />
|
| 552 |
-
</div>
|
| 553 |
-
);
|
| 554 |
-
}
|
| 555 |
-
|
| 556 |
-
// ── Packages page tour ──
|
| 557 |
-
const PACKAGES_TOUR_KEY = "labas-page-tour-packages";
|
| 558 |
-
const packagesPageSteps: Step[] = [
|
| 559 |
-
{
|
| 560 |
-
target: "[data-tour='packages-header']",
|
| 561 |
-
title: "Paket Soal",
|
| 562 |
-
content: "Temukan paket soal dari komunitas atau lihat paket buatan sendiri. Klik 'Buat Paket' untuk membuat paket baru dari Bank Soal.",
|
| 563 |
-
spotlightPadding: 8,
|
| 564 |
-
},
|
| 565 |
-
{
|
| 566 |
-
target: "[data-tour='packages-filters']",
|
| 567 |
-
title: "Filter & Pencarian",
|
| 568 |
-
content: "Cari paket berdasarkan nama atau filter berdasarkan jenis ujian (IELTS, TOEFL, dll). Bisa juga switch antara 'Semua Paket' dan 'Paket Saya'.",
|
| 569 |
-
spotlightPadding: 8,
|
| 570 |
-
},
|
| 571 |
-
{
|
| 572 |
-
target: "[data-tour='packages-list']",
|
| 573 |
-
title: "Mulai Latihan",
|
| 574 |
-
content: "Klik kartu paket untuk lihat detail, atau langsung klik 'Mulai Latihan' untuk mengerjakan soal. Pantau skor dan progres kamu!",
|
| 575 |
-
spotlightPadding: 8,
|
| 576 |
-
},
|
| 577 |
-
];
|
| 578 |
-
|
| 579 |
-
function VisChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
| 580 |
-
return (
|
| 581 |
-
<button
|
| 582 |
-
onClick={onClick}
|
| 583 |
-
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
|
| 584 |
-
active
|
| 585 |
-
? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
|
| 586 |
-
: "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
|
| 587 |
-
}`}
|
| 588 |
-
>
|
| 589 |
-
{children}
|
| 590 |
-
</button>
|
| 591 |
-
);
|
| 592 |
-
}
|
|
|
|
| 1 |
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
|
|
|
|
|
| 2 |
import { z } from "zod";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export const Route = createFileRoute("/packages")({
|
|
|
|
| 6 |
validateSearch: z.object({
|
| 7 |
tab: z.enum(["all", "mine"]).optional(),
|
| 8 |
search: z.string().optional(),
|
|
|
|
| 18 |
return { session };
|
| 19 |
},
|
| 20 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/web/src/routes/settings.lazy.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { RouteComponent } from "@/components/routes/SettingsPage";
|
| 3 |
+
|
| 4 |
+
export const Route = createLazyFileRoute("/settings")({
|
| 5 |
+
component: RouteComponent,
|
| 6 |
+
});
|
apps/web/src/routes/settings.tsx
CHANGED
|
@@ -1,27 +1,8 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 3 |
-
import { useQuery } from "@tanstack/react-query";
|
| 4 |
-
import { authClient } from "@/lib/auth-client";
|
| 5 |
-
import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
|
| 6 |
-
import { trpc } from "@/utils/trpc";
|
| 7 |
-
import {
|
| 8 |
-
Tabs,
|
| 9 |
-
TabsList,
|
| 10 |
-
TabsTrigger,
|
| 11 |
-
TabsContent,
|
| 12 |
-
} from "@labas/ui/components/tabs";
|
| 13 |
-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 14 |
-
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 15 |
-
import { TokenUsageChart } from "@/components/settings/TokenUsageChart";
|
| 16 |
-
import { ApiKeyList } from "@/components/settings/ApiKeyList";
|
| 17 |
-
import { ApiKeyForm } from "@/components/settings/ApiKeyForm";
|
| 18 |
-
import { TipsCard } from "@/components/settings/TipsCard";
|
| 19 |
-
import { SecurityInfo } from "@/components/settings/SecurityInfo";
|
| 20 |
-
import { AccountSettings } from "@/components/settings/AccountSettings";
|
| 21 |
import { z } from "zod";
|
|
|
|
| 22 |
|
| 23 |
export const Route = createFileRoute("/settings")({
|
| 24 |
-
component: RouteComponent,
|
| 25 |
validateSearch: z.object({
|
| 26 |
tab: z.enum(["api-keys", "token-usage", "security", "account"]).optional(),
|
| 27 |
}).parse,
|
|
@@ -33,320 +14,3 @@ export const Route = createFileRoute("/settings")({
|
|
| 33 |
return { session };
|
| 34 |
},
|
| 35 |
});
|
| 36 |
-
|
| 37 |
-
const PROVIDERS = [
|
| 38 |
-
{ value: "openai", label: "OpenAI" },
|
| 39 |
-
{ value: "anthropic", label: "Anthropic" },
|
| 40 |
-
{ value: "google", label: "Google" },
|
| 41 |
-
{ value: "openrouter", label: "OpenRouter" },
|
| 42 |
-
{ value: "groq", label: "Groq" },
|
| 43 |
-
{ value: "custom", label: "Custom" },
|
| 44 |
-
];
|
| 45 |
-
|
| 46 |
-
function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
|
| 47 |
-
return {
|
| 48 |
-
name: "",
|
| 49 |
-
provider: "openai",
|
| 50 |
-
baseUrl: "https://api.openai.com/v1",
|
| 51 |
-
modelName: "gpt-4o-mini",
|
| 52 |
-
maxTokens: 16384,
|
| 53 |
-
};
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
type Tab = "api-keys" | "token-usage" | "security" | "account";
|
| 57 |
-
|
| 58 |
-
function TokenUsageSection() {
|
| 59 |
-
const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
|
| 60 |
-
|
| 61 |
-
const totalTokens = data?.totalTokens ?? 0;
|
| 62 |
-
const jobs = data?.jobs ?? [];
|
| 63 |
-
|
| 64 |
-
return (
|
| 65 |
-
<div className="space-y-6">
|
| 66 |
-
<TokenUsageChart />
|
| 67 |
-
|
| 68 |
-
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 69 |
-
<CardHeader>
|
| 70 |
-
<div className="flex items-center gap-3">
|
| 71 |
-
<MaterialIcon name="toll" className="text-xl" />
|
| 72 |
-
<div>
|
| 73 |
-
<CardTitle className="font-headline text-[var(--clay-black)]">
|
| 74 |
-
Penggunaan Token Hari Ini
|
| 75 |
-
</CardTitle>
|
| 76 |
-
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 77 |
-
Total token yang terpakai untuk generate soal hari ini.
|
| 78 |
-
</CardDescription>
|
| 79 |
-
</div>
|
| 80 |
-
</div>
|
| 81 |
-
</CardHeader>
|
| 82 |
-
<CardContent className="space-y-6">
|
| 83 |
-
<div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
|
| 84 |
-
<MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
|
| 85 |
-
<div>
|
| 86 |
-
<p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
|
| 87 |
-
<p className="text-3xl font-extrabold text-[var(--matcha-800)]">
|
| 88 |
-
{isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
|
| 89 |
-
</p>
|
| 90 |
-
</div>
|
| 91 |
-
</div>
|
| 92 |
-
|
| 93 |
-
<div>
|
| 94 |
-
<h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
|
| 95 |
-
Riwayat Generate Hari Ini
|
| 96 |
-
</h3>
|
| 97 |
-
{isLoading ? (
|
| 98 |
-
<div className="space-y-2">
|
| 99 |
-
{[1, 2, 3].map((i) => (
|
| 100 |
-
<div key={i} className="h-12 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
|
| 101 |
-
))}
|
| 102 |
-
</div>
|
| 103 |
-
) : jobs.length === 0 ? (
|
| 104 |
-
<div className="text-center py-8 border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-lg)]">
|
| 105 |
-
<MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
|
| 106 |
-
<p className="text-[var(--warm-charcoal)] font-semibold">Belum ada generate hari ini</p>
|
| 107 |
-
<p className="text-xs text-[var(--warm-silver)] mt-1">
|
| 108 |
-
Generate soal baru untuk melihat penggunaan token.
|
| 109 |
-
</p>
|
| 110 |
-
</div>
|
| 111 |
-
) : (
|
| 112 |
-
<div className="overflow-x-auto">
|
| 113 |
-
<table className="w-full text-sm">
|
| 114 |
-
<thead>
|
| 115 |
-
<tr className="border-b border-[var(--oat-border)] text-[var(--warm-charcoal)]">
|
| 116 |
-
<th className="text-left py-2 px-3 font-medium">Waktu</th>
|
| 117 |
-
<th className="text-left py-2 px-3 font-medium">Mode</th>
|
| 118 |
-
<th className="text-left py-2 px-3 font-medium">Status</th>
|
| 119 |
-
<th className="text-left py-2 px-3 font-medium">Ujian</th>
|
| 120 |
-
<th className="text-left py-2 px-3 font-medium">Section</th>
|
| 121 |
-
<th className="text-right py-2 px-3 font-medium">Soal</th>
|
| 122 |
-
<th className="text-right py-2 px-3 font-medium">Token</th>
|
| 123 |
-
</tr>
|
| 124 |
-
</thead>
|
| 125 |
-
<tbody>
|
| 126 |
-
{jobs.map((job) => {
|
| 127 |
-
const isFailed = job.status === "failed";
|
| 128 |
-
const isCancelled = job.status === "cancelled";
|
| 129 |
-
return (
|
| 130 |
-
<tr
|
| 131 |
-
key={job.id}
|
| 132 |
-
className={`border-b border-[var(--oat-border)] last:border-0 hover:bg-[var(--warm-cream)] transition-colors ${
|
| 133 |
-
isFailed || isCancelled ? "opacity-70" : ""
|
| 134 |
-
}`}
|
| 135 |
-
>
|
| 136 |
-
<td className="py-2.5 px-3 text-[var(--clay-black)] whitespace-nowrap">
|
| 137 |
-
{new Date(job.createdAt).toLocaleTimeString("id-ID", {
|
| 138 |
-
hour: "2-digit",
|
| 139 |
-
minute: "2-digit",
|
| 140 |
-
})}
|
| 141 |
-
</td>
|
| 142 |
-
<td className="py-2.5 px-3">
|
| 143 |
-
<span
|
| 144 |
-
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
|
| 145 |
-
job.mode === "agentic"
|
| 146 |
-
? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 147 |
-
: "bg-[var(--lavender-300)] text-[var(--lavender-800)]"
|
| 148 |
-
}`}
|
| 149 |
-
>
|
| 150 |
-
{job.mode === "agentic" ? "Agentic" : "Quick"}
|
| 151 |
-
</span>
|
| 152 |
-
</td>
|
| 153 |
-
<td className="py-2.5 px-3">
|
| 154 |
-
{job.status === "completed" ? (
|
| 155 |
-
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--matcha-800)]">
|
| 156 |
-
<MaterialIcon name="check_circle" className="text-xs" />
|
| 157 |
-
Selesai
|
| 158 |
-
</span>
|
| 159 |
-
) : isFailed ? (
|
| 160 |
-
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--pomegranate-400)]">
|
| 161 |
-
<MaterialIcon name="error" className="text-xs" />
|
| 162 |
-
Gagal
|
| 163 |
-
</span>
|
| 164 |
-
) : isCancelled ? (
|
| 165 |
-
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-charcoal)]">
|
| 166 |
-
<MaterialIcon name="cancel" className="text-xs" />
|
| 167 |
-
Dibatalkan
|
| 168 |
-
</span>
|
| 169 |
-
) : (
|
| 170 |
-
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-silver)]">
|
| 171 |
-
<MaterialIcon name="hourglass_empty" className="text-xs" />
|
| 172 |
-
{job.status}
|
| 173 |
-
</span>
|
| 174 |
-
)}
|
| 175 |
-
</td>
|
| 176 |
-
<td className="py-2.5 px-3 text-[var(--clay-black)]">{job.examTypeId}</td>
|
| 177 |
-
<td className="py-2.5 px-3 text-[var(--clay-black)]">{job.sectionTypeId}</td>
|
| 178 |
-
<td className="py-2.5 px-3 text-right text-[var(--clay-black)]">{job.questionCount}</td>
|
| 179 |
-
<td className="py-2.5 px-3 text-right text-[var(--clay-black)] font-medium">
|
| 180 |
-
{job.tokensUsed?.toLocaleString("id-ID") ?? "-"}
|
| 181 |
-
</td>
|
| 182 |
-
</tr>
|
| 183 |
-
);
|
| 184 |
-
})}
|
| 185 |
-
</tbody>
|
| 186 |
-
</table>
|
| 187 |
-
</div>
|
| 188 |
-
)}
|
| 189 |
-
</div>
|
| 190 |
-
</CardContent>
|
| 191 |
-
</Card>
|
| 192 |
-
</div>
|
| 193 |
-
);
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
function RouteComponent() {
|
| 197 |
-
const { configs, isLoading, addConfig, updateConfig, removeConfig } =
|
| 198 |
-
useApiKeys();
|
| 199 |
-
|
| 200 |
-
const search = Route.useSearch();
|
| 201 |
-
const navigate = Route.useNavigate();
|
| 202 |
-
|
| 203 |
-
const [editingId, setEditingId] = useState<string | null>(null);
|
| 204 |
-
const [isAdding, setIsAdding] = useState(false);
|
| 205 |
-
const [isSaving, setIsSaving] = useState(false);
|
| 206 |
-
|
| 207 |
-
const activeTab: Tab = search.tab ?? "api-keys";
|
| 208 |
-
|
| 209 |
-
const setTab = (tab: string) => {
|
| 210 |
-
startTransition(() => {
|
| 211 |
-
navigate({ search: { tab: tab as Tab } });
|
| 212 |
-
});
|
| 213 |
-
};
|
| 214 |
-
|
| 215 |
-
const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>(
|
| 216 |
-
() => ({
|
| 217 |
-
...defaultConfig(),
|
| 218 |
-
apiKey: "",
|
| 219 |
-
}),
|
| 220 |
-
);
|
| 221 |
-
|
| 222 |
-
const resetForm = () => {
|
| 223 |
-
setForm({ ...defaultConfig(), apiKey: "" });
|
| 224 |
-
};
|
| 225 |
-
|
| 226 |
-
const handleFormChange = (field: keyof typeof form, value: string | number) => {
|
| 227 |
-
setForm((prev) => ({ ...prev, [field]: value }));
|
| 228 |
-
};
|
| 229 |
-
|
| 230 |
-
const startAdd = () => {
|
| 231 |
-
resetForm();
|
| 232 |
-
setIsAdding(true);
|
| 233 |
-
setEditingId(null);
|
| 234 |
-
};
|
| 235 |
-
|
| 236 |
-
const startEdit = (config: ApiKeyConfig) => {
|
| 237 |
-
setForm({
|
| 238 |
-
name: config.name,
|
| 239 |
-
provider: config.provider,
|
| 240 |
-
baseUrl: config.baseUrl,
|
| 241 |
-
modelName: config.modelName,
|
| 242 |
-
maxTokens: config.maxTokens ?? 16384,
|
| 243 |
-
apiKey: "",
|
| 244 |
-
});
|
| 245 |
-
setEditingId(config.id);
|
| 246 |
-
setIsAdding(false);
|
| 247 |
-
};
|
| 248 |
-
|
| 249 |
-
const cancelEdit = () => {
|
| 250 |
-
setEditingId(null);
|
| 251 |
-
setIsAdding(false);
|
| 252 |
-
resetForm();
|
| 253 |
-
};
|
| 254 |
-
|
| 255 |
-
const handleSave = async () => {
|
| 256 |
-
if (!form.name.trim()) return;
|
| 257 |
-
setIsSaving(true);
|
| 258 |
-
try {
|
| 259 |
-
if (isAdding) {
|
| 260 |
-
if (!form.apiKey) return;
|
| 261 |
-
await addConfig(form);
|
| 262 |
-
setIsAdding(false);
|
| 263 |
-
} else if (editingId) {
|
| 264 |
-
await updateConfig(editingId, form);
|
| 265 |
-
setEditingId(null);
|
| 266 |
-
}
|
| 267 |
-
resetForm();
|
| 268 |
-
} finally {
|
| 269 |
-
setIsSaving(false);
|
| 270 |
-
}
|
| 271 |
-
};
|
| 272 |
-
|
| 273 |
-
const isFormOpen = isAdding || editingId !== null;
|
| 274 |
-
const canSave =
|
| 275 |
-
!!form.name.trim() &&
|
| 276 |
-
!!form.baseUrl.trim() &&
|
| 277 |
-
!!form.modelName.trim() &&
|
| 278 |
-
(isAdding ? !!form.apiKey : true);
|
| 279 |
-
|
| 280 |
-
return (
|
| 281 |
-
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 282 |
-
<section className="mb-10">
|
| 283 |
-
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 284 |
-
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">Beranda</Link>
|
| 285 |
-
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 286 |
-
<span className="text-[var(--clay-black)] font-medium">Pengaturan</span>
|
| 287 |
-
</div>
|
| 288 |
-
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 289 |
-
Pengaturan
|
| 290 |
-
</h1>
|
| 291 |
-
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 292 |
-
Kelola API key, pantau penggunaan token, dan preferensi akun.
|
| 293 |
-
</p>
|
| 294 |
-
</section>
|
| 295 |
-
|
| 296 |
-
<Tabs value={activeTab} onValueChange={setTab} className="w-full">
|
| 297 |
-
<TabsList variant="line" className="mb-6">
|
| 298 |
-
<TabsTrigger value="api-keys">API Keys</TabsTrigger>
|
| 299 |
-
<TabsTrigger value="token-usage">Token Usage</TabsTrigger>
|
| 300 |
-
<TabsTrigger value="account">Akun</TabsTrigger>
|
| 301 |
-
<TabsTrigger value="security">Keamanan</TabsTrigger>
|
| 302 |
-
</TabsList>
|
| 303 |
-
|
| 304 |
-
<TabsContent value="api-keys" className="space-y-8">
|
| 305 |
-
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
| 306 |
-
<div className="lg:col-span-7 space-y-8">
|
| 307 |
-
<ApiKeyList
|
| 308 |
-
configs={configs}
|
| 309 |
-
isLoading={isLoading}
|
| 310 |
-
editingId={editingId}
|
| 311 |
-
isFormOpen={isFormOpen}
|
| 312 |
-
providers={PROVIDERS}
|
| 313 |
-
onStartAdd={startAdd}
|
| 314 |
-
onStartEdit={startEdit}
|
| 315 |
-
onRemove={removeConfig}
|
| 316 |
-
/>
|
| 317 |
-
|
| 318 |
-
{isFormOpen && (
|
| 319 |
-
<ApiKeyForm
|
| 320 |
-
isAdding={isAdding}
|
| 321 |
-
isSaving={isSaving}
|
| 322 |
-
form={form}
|
| 323 |
-
canSave={canSave}
|
| 324 |
-
providers={PROVIDERS}
|
| 325 |
-
onChange={handleFormChange}
|
| 326 |
-
onSave={handleSave}
|
| 327 |
-
onCancel={cancelEdit}
|
| 328 |
-
/>
|
| 329 |
-
)}
|
| 330 |
-
</div>
|
| 331 |
-
|
| 332 |
-
<div className="lg:col-span-5">
|
| 333 |
-
<TipsCard />
|
| 334 |
-
</div>
|
| 335 |
-
</div>
|
| 336 |
-
</TabsContent>
|
| 337 |
-
|
| 338 |
-
<TabsContent value="token-usage">
|
| 339 |
-
<TokenUsageSection />
|
| 340 |
-
</TabsContent>
|
| 341 |
-
|
| 342 |
-
<TabsContent value="account">
|
| 343 |
-
<AccountSettings />
|
| 344 |
-
</TabsContent>
|
| 345 |
-
|
| 346 |
-
<TabsContent value="security">
|
| 347 |
-
<SecurityInfo />
|
| 348 |
-
</TabsContent>
|
| 349 |
-
</Tabs>
|
| 350 |
-
</div>
|
| 351 |
-
);
|
| 352 |
-
}
|
|
|
|
| 1 |
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { z } from "zod";
|
| 3 |
+
import { authClient } from "@/lib/auth-client";
|
| 4 |
|
| 5 |
export const Route = createFileRoute("/settings")({
|
|
|
|
| 6 |
validateSearch: z.object({
|
| 7 |
tab: z.enum(["api-keys", "token-usage", "security", "account"]).optional(),
|
| 8 |
}).parse,
|
|
|
|
| 14 |
return { session };
|
| 15 |
},
|
| 16 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|